mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Compare commits
215
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1205f3be | ||
|
|
e40214f0f8 | ||
|
|
90b177242f | ||
|
|
a536df3130 | ||
|
|
cff08c7e93 | ||
|
|
fe5b23e088 | ||
|
|
934f3c9d63 | ||
|
|
7a0ae0fa81 | ||
|
|
05ca19c11f | ||
|
|
3e32b0a80c | ||
|
|
1c94719b11 | ||
|
|
bf28cc2f7e | ||
|
|
55bdcbb73c | ||
|
|
1a3b313f0d | ||
|
|
dd9820eb2d | ||
|
|
48c0267615 | ||
|
|
629d061325 | ||
|
|
9f86df49e2 | ||
|
|
4ac0d20e35 | ||
|
|
194a1ebb4c | ||
|
|
fd4fb309b5 | ||
|
|
d8d8ee91c8 | ||
|
|
ab822eef53 | ||
|
|
a5132dad87 | ||
|
|
c7b74fa08f | ||
|
|
a8401f6181 | ||
|
|
e5de3abd12 | ||
|
|
5c87145e54 | ||
|
|
bdf4cf6844 | ||
|
|
40d07241ab | ||
|
|
de2dbd4f3a | ||
|
|
6af2754824 | ||
|
|
2f8f26952b | ||
|
|
2673e30ec6 | ||
|
|
eb4acf2c1d | ||
|
|
53e9409306 | ||
|
|
63ee206e93 | ||
|
|
fcfc7dc93c | ||
|
|
ab01331f50 | ||
|
|
6b7043a04d | ||
|
|
315745a167 | ||
|
|
578bd5c57d | ||
|
|
cdce3a6864 | ||
|
|
c7b6df192f | ||
|
|
4e277d7744 | ||
|
|
0a9fb436cd | ||
|
|
e70694f660 | ||
|
|
66785c8ee7 | ||
|
|
fad10a9311 | ||
|
|
3ad1df6339 | ||
|
|
17812d5d3b | ||
|
|
3071234eca | ||
|
|
de62cc2593 | ||
|
|
d7af73706a | ||
|
|
5a78aedc02 | ||
|
|
56b90d48c3 | ||
|
|
2e4067f41d | ||
|
|
e9b1251591 | ||
|
|
878a18296d | ||
|
|
437eaafbb5 | ||
|
|
d50f7b9aa5 | ||
|
|
388ea09e5b | ||
|
|
2def07e878 | ||
|
|
462550807a | ||
|
|
6a7e38fcf3 | ||
|
|
f1e4192975 | ||
|
|
aefe4b4a5d | ||
|
|
501f77766b | ||
|
|
e49bae30eb | ||
|
|
910d9786b5 | ||
|
|
5f3844fa39 | ||
|
|
9e6faddc32 | ||
|
|
6f90d644b4 | ||
|
|
6c7cc0d3f6 | ||
|
|
f657e53c3a | ||
|
|
f09dc5f6c5 | ||
|
|
b2d121c4d4 | ||
|
|
6696649095 | ||
|
|
331e9321e9 | ||
|
|
0843d4bc30 | ||
|
|
186fc4b221 | ||
|
|
c56934f427 | ||
|
|
69df0f1c99 | ||
|
|
2b6224c2be | ||
|
|
cee9a8481a | ||
|
|
69c1bffe7b | ||
|
|
5109053de5 | ||
|
|
6c52cbfef4 | ||
|
|
6aa68d89b2 | ||
|
|
004e36f2fa | ||
|
|
d8ef8dd18a | ||
|
|
89e25027f8 | ||
|
|
4a333f294b | ||
|
|
9efaa249f1 | ||
|
|
fbddd1155e | ||
|
|
a5d5fc3eae | ||
|
|
bad82b3a7b | ||
|
|
efa6f41575 | ||
|
|
bc9c9025b8 | ||
|
|
daccae566f | ||
|
|
9010f4563a | ||
|
|
44b7a3d872 | ||
|
|
8411d0bb0d | ||
|
|
3cafd3927d | ||
|
|
3b7ffa7d3b | ||
|
|
3d8d7e83c8 | ||
|
|
09f8cc8682 | ||
|
|
638c541151 | ||
|
|
c377fa935c | ||
|
|
0cbeca0c6d | ||
|
|
18bbd7f08f | ||
|
|
14a91700ca | ||
|
|
55385068f1 | ||
|
|
69479d9a39 | ||
|
|
85d0844303 | ||
|
|
a4071441c3 | ||
|
|
31b9d63307 | ||
|
|
beb3cc453e | ||
|
|
a41d1091be | ||
|
|
ca5f36d5b7 | ||
|
|
74cf893c00 | ||
|
|
37e2d5fda7 | ||
|
|
0d7e6eca65 | ||
|
|
233ec8128c | ||
|
|
43a0ace5d0 | ||
|
|
848b0327be | ||
|
|
2500ee028e | ||
|
|
105ed7e34d | ||
|
|
c44507fd11 | ||
|
|
30cf604d4c | ||
|
|
e4619cf13a | ||
|
|
a8adfe257a | ||
|
|
8ca8e43753 | ||
|
|
8b074614dc | ||
|
|
d10d277ebf | ||
|
|
9632c2b9bf | ||
|
|
b8758ed0ee | ||
|
|
fd22057318 | ||
|
|
138618637c | ||
|
|
4eda5c5ef4 | ||
|
|
d8881d867e | ||
|
|
697f8e60f8 | ||
|
|
3023de8112 | ||
|
|
bc2c899b45 | ||
|
|
e74ebbc91b | ||
|
|
e4a03ae48b | ||
|
|
b9e64c004a | ||
|
|
da581a9471 | ||
|
|
c10b8f8f81 | ||
|
|
81262deb52 | ||
|
|
fccb65a211 | ||
|
|
ae4ebb53a7 | ||
|
|
93a36c5f57 | ||
|
|
0aefd99a0e | ||
|
|
db8c54208a | ||
|
|
d4088d042e | ||
|
|
48f3c9dd25 | ||
|
|
b60725bccd | ||
|
|
36f6cc199b | ||
|
|
357d490ef3 | ||
|
|
e71ac524dc | ||
|
|
0dc01f188e | ||
|
|
9e9b3e020b | ||
|
|
68eb886041 | ||
|
|
6e0d9f8943 | ||
|
|
099edb33e1 | ||
|
|
39ab0f5508 | ||
|
|
9199ed7954 | ||
|
|
b9330f635b | ||
|
|
4fcd9764eb | ||
|
|
9abb516e6a | ||
|
|
428529d883 | ||
|
|
3f7e7e1ce5 | ||
|
|
1663286d33 | ||
|
|
496aef5a28 | ||
|
|
7a29f192d0 | ||
|
|
94ed046c72 | ||
|
|
d6521cb89d | ||
|
|
8c1479faf7 | ||
|
|
ef2b03272a | ||
|
|
2f4722cd03 | ||
|
|
8c10e9632e | ||
|
|
971da63cc9 | ||
|
|
778aa94ae9 | ||
|
|
fa4cd5034c | ||
|
|
1cefe8e07b | ||
|
|
e653db411e | ||
|
|
d6981151ef | ||
|
|
672d724dff | ||
|
|
1ba4540451 | ||
|
|
72b1dc7069 | ||
|
|
a6c1487ffa | ||
|
|
d16fadcbb0 | ||
|
|
4ab7ee68a3 | ||
|
|
464b810fec | ||
|
|
fa867b5ae6 | ||
|
|
9c16026744 | ||
|
|
8eef407062 | ||
|
|
70998e8ea8 | ||
|
|
aa4f1659ee | ||
|
|
b17c8857c4 | ||
|
|
0f564a2905 | ||
|
|
ec47323f5e | ||
|
|
959394a4ca | ||
|
|
76006c2ece | ||
|
|
fe0a000d51 | ||
|
|
b56ce63b13 | ||
|
|
0e4bfaa418 | ||
|
|
4b0544a127 | ||
|
|
7ac72c37d0 | ||
|
|
e6f854a3e6 | ||
|
|
dd0385e310 | ||
|
|
4771f9adfa | ||
|
|
83df1da3d4 | ||
|
|
ebf106100b |
No files matched your search
+10
-1
@@ -3,9 +3,14 @@ dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# All docs are internal except as-built
|
||||
# All docs are internal except as-built and reference
|
||||
docs/*
|
||||
!docs/as-built/
|
||||
!docs/reference/
|
||||
!docs/releases/
|
||||
!docs/releases/**
|
||||
|
||||
notes/*
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
@@ -16,6 +21,10 @@ docs/*
|
||||
*.sqlite-wal
|
||||
*.sqlite.pre-reboot-backup
|
||||
.openrig/
|
||||
.claude
|
||||
.agents
|
||||
.openrig
|
||||
.worktrees/
|
||||
|
||||
# Local demo proof artifacts
|
||||
demo/proof/
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Mike Schwarz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,174 @@
|
||||
# OpenRig
|
||||
|
||||
Open source multi-agent harness for coding teams.
|
||||
|
||||
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g @openrig/cli
|
||||
|
||||
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
|
||||
rig setup
|
||||
|
||||
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
|
||||
rig up demo
|
||||
|
||||
# Open the UI
|
||||
rig ui open
|
||||
```
|
||||
|
||||
After the demo boots, open the UI and click **orch1.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
|
||||
|
||||
## Launch Walkthrough
|
||||
|
||||
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
|
||||
|
||||
### 1. Open the demo rig
|
||||
|
||||
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
|
||||
|
||||

|
||||
|
||||
### 2. Click `CMUX` on `orch1.lead`
|
||||
|
||||
Once the topology loads, go to the `orch1.lead` node and click its `CMUX` button. That opens the orchestrator terminal directly.
|
||||
|
||||

|
||||
|
||||
> From here just talk to the orchestrator and tell it what you want it to build...
|
||||
|
||||
## What It Does
|
||||
|
||||
OpenRig is a multi-agent harness — it manages the system that coding agents form when you run them together. Not the agents themselves, but the team they create: which sessions are running, how they relate, how to recover after a reboot, and how to stop it from becoming terminal sprawl.
|
||||
|
||||
- **Define** topologies in YAML (RigSpec) with pods, edges, and continuity policies
|
||||
- **Boot** everything with `rig up` — tmux sessions, harnesses, startup files, readiness checks
|
||||
- **See** the topology in a live graph with explorer, node detail, and system log
|
||||
- **Discover** existing Claude Code and Codex sessions in tmux and adopt them into a managed rig
|
||||
- **Snapshot** the full topology on `rig down`, restore by name with `rig up <name>`
|
||||
- **Communicate** across agents with `rig send`, `rig broadcast`, and `rig chatroom`
|
||||
- **Evolve** running topologies with `rig expand`, `rig shrink`, `rig launch`, `rig remove`
|
||||
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
|
||||
## Demo Rig
|
||||
|
||||
OpenRig ships with a demo rig you can boot in seconds:
|
||||
|
||||
```bash
|
||||
rig specs preview demo
|
||||
```
|
||||
|
||||
```
|
||||
demo (rig, pod_aware)
|
||||
Launch-grade starter: a stable full product squad with two
|
||||
orchestrators, implementation, QA, design, and two independent
|
||||
reviewers.
|
||||
|
||||
Pod: orch1 (2 members)
|
||||
lead — claude-code
|
||||
peer — codex
|
||||
Pod: dev1 (3 members)
|
||||
impl — claude-code
|
||||
qa — codex
|
||||
design — claude-code
|
||||
Pod: rev1 (2 members)
|
||||
r1 — claude-code
|
||||
r2 — codex
|
||||
```
|
||||
|
||||
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, `product-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
|
||||
|
||||
Browse the library: `rig specs ls`
|
||||
|
||||
## How It Works
|
||||
|
||||
OpenRig is a local daemon + CLI + MCP server + React UI, built on tmux.
|
||||
|
||||
```
|
||||
CLI / UI / MCP
|
||||
|
|
||||
Hono HTTP daemon
|
||||
|
|
||||
Domain services (52)
|
||||
|
|
||||
SQLite + tmux + runtime adapters
|
||||
```
|
||||
|
||||
- **CLI**: 40+ commands designed for both humans and agents. Every mutating command ends with what happened, current state, and next action.
|
||||
- **UI**: Explorer sidebar, topology graph with pod grouping, node detail panel, system log, chatroom.
|
||||
- **MCP**: 17 tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
|
||||
- **Runtimes**: Claude Code, Codex, and terminal nodes. Adapters for Pi and OpenCode in development.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **RigSpec**: Declarative multi-agent harness definition in YAML. Pods, members, edges, continuity policies, culture file.
|
||||
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
|
||||
- **Pod**: Bounded context group. Agents in a pod share memory and can maintain each other's context.
|
||||
- **Discovery**: `rig discover` fingerprints existing tmux sessions. `rig adopt` brings them under management.
|
||||
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes (resumed, fresh, or failed).
|
||||
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.
|
||||
- **Culture**: CULTURE.md sets coordination norms for the group. Research rigs get exploratory culture. Implementation rigs get conservative, trust-but-verify culture.
|
||||
|
||||
## Agent-Managed Software
|
||||
|
||||
A rig can package actual software alongside the agents that manage it. The shipped example is `secrets-manager`: a HashiCorp Vault instance operated by a specialist agent.
|
||||
|
||||
```bash
|
||||
rig up secrets-manager
|
||||
rig env status secrets-manager
|
||||
rig send vault-specialist@secrets-manager "Check Vault health and report status." --verify
|
||||
```
|
||||
|
||||
Requires Docker for service-backed rigs.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20, 22, or 24 (even-numbered LTS releases; odd releases like 25 lack native addon prebuilds)
|
||||
- tmux
|
||||
|
||||
Optional:
|
||||
- cmux for `Open CMUX` node surface controls
|
||||
- Docker for service-backed rigs and managed apps
|
||||
|
||||
## Setup and Troubleshooting
|
||||
|
||||
- `rig setup` attempts core machine preparation: tmux, cmux, Claude Code, Codex, and tmux defaults. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
|
||||
- `rig setup --full` attempts a broader operator workstation setup (jq, gh) on top of core.
|
||||
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes.
|
||||
|
||||
Both commands support `--json` for agent-driven workflows.
|
||||
|
||||
Managed runtime boot (during `rig up`) may modify runtime config for core bootstrap and spec-selected runtime resources. `rig setup` discloses these paths so agents know what may be changed:
|
||||
- global Claude: `~/.claude/settings.json` for minimal OpenRig command allowlisting
|
||||
- global Claude state: `~/.claude.json` for managed workspace trust and onboarding completion
|
||||
- project Claude: `.claude/settings.local.json` for context collector/activity hooks and selected `claude_settings_fragment` resources
|
||||
- project Claude MCP: `.mcp.json` for selected `claude_mcp_fragment` resources
|
||||
- global Codex: `~/.codex/config.toml` for workspace trust and selected `codex_config_fragment` resources
|
||||
|
||||
Already-running adopted sessions may need restart before they pick up newly written runtime config.
|
||||
|
||||
**For agents:** Ask the user whether they want core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) before choosing the invocation. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues.
|
||||
|
||||
## Comparison with Claude Managed Agents
|
||||
|
||||
Anthropic shipped Claude Managed Agents — a cloud-hosted, Claude-only runtime at $0.08/session-hour. OpenRig is the local side: open source, cross-harness, runs on your machine, costs nothing.
|
||||
|
||||
[Full comparison](https://openrig.dev/compare/claude-managed-agents)
|
||||
|
||||
## Links
|
||||
|
||||
- **Website**: [openrig.dev](https://openrig.dev)
|
||||
- **Blog**: [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
|
||||
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs)
|
||||
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
|
||||
- **Twitter**: [@_feralmachine](https://twitter.com/_feralmachine)
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
@@ -0,0 +1,24 @@
|
||||
# Launch Happy-Path Screenshot Set
|
||||
|
||||
Source capture:
|
||||
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
|
||||
- host-forwarded UI: `http://127.0.0.1:17439`
|
||||
- guest daemon: `192.168.64.9:7433`
|
||||
|
||||
Purpose:
|
||||
- README walkthrough
|
||||
- website/docs reuse
|
||||
- launch gate evidence bundle
|
||||
|
||||
Annotated README walkthrough:
|
||||
- `guide-step-01-open-demo.png` — click the `demo` rig in the explorer
|
||||
- `guide-step-02-open-cmux.png` — click the `CMUX` button on `orch1.lead` in the topology
|
||||
|
||||
Archived proof/source bundle:
|
||||
- raw proof captures and guide source were moved out of the repo to:
|
||||
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
|
||||
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
|
||||
|
||||
Notes:
|
||||
- The README should use the annotated walkthrough files.
|
||||
- The raw captures are still the fidelity record for proof and future reuse, just not inside the product repo.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
+1
-1
@@ -62,7 +62,7 @@ pods:
|
||||
files: []
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "rigged status"
|
||||
value: "rig status"
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
applies_on: [fresh_start]
|
||||
|
||||
+7
-7
@@ -10,11 +10,11 @@ echo ""
|
||||
|
||||
# 0. Start or reuse daemon
|
||||
echo "Step 0: Ensure daemon is running..."
|
||||
if rigged daemon start; then
|
||||
if rig daemon start; then
|
||||
sleep 2
|
||||
else
|
||||
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
|
||||
if rigged ps --json >/dev/null 2>&1; then
|
||||
if rig ps --json >/dev/null 2>&1; then
|
||||
echo "Reusing existing healthy daemon."
|
||||
else
|
||||
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
|
||||
@@ -25,7 +25,7 @@ echo ""
|
||||
|
||||
# 1. Boot
|
||||
echo "Step 1: Boot demo topology..."
|
||||
UP_OUTPUT=$(rigged up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
|
||||
UP_OUTPUT=$(rig up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
|
||||
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
|
||||
if [ -z "$RIG_ID" ]; then
|
||||
echo "ERROR: No rig ID found in boot output. Proof failed."
|
||||
@@ -35,7 +35,7 @@ echo ""
|
||||
|
||||
# 2. Node status
|
||||
echo "Step 2: Node status after boot..."
|
||||
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
|
||||
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
|
||||
echo ""
|
||||
|
||||
# 3. Health check after boot
|
||||
@@ -74,7 +74,7 @@ echo "Rig ID: $RIG_ID"
|
||||
# 7. Tear down
|
||||
echo ""
|
||||
echo "Step 7: Tear down..."
|
||||
DOWN_OUTPUT=$(rigged down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
|
||||
DOWN_OUTPUT=$(rig down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
|
||||
SNAPSHOT_ID=$(printf '%s\n' "$DOWN_OUTPUT" | sed -n 's/^Snapshot: //p' | tail -n1)
|
||||
if [ -z "$SNAPSHOT_ID" ]; then
|
||||
echo "ERROR: No snapshot ID found in teardown output. Proof failed."
|
||||
@@ -90,12 +90,12 @@ echo ""
|
||||
|
||||
# 9. Restore via explicit snapshot + rig ID
|
||||
echo "Step 9: Restore via explicit snapshot + rig ID..."
|
||||
rigged restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
|
||||
rig restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
|
||||
echo ""
|
||||
|
||||
# 10. Node status after restore
|
||||
echo "Step 10: Node status after restore..."
|
||||
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
|
||||
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
|
||||
echo ""
|
||||
|
||||
echo "=== Automated proof artifacts produced ==="
|
||||
|
||||
+8
-8
@@ -6,11 +6,11 @@ echo ""
|
||||
|
||||
# Start daemon
|
||||
echo "Starting daemon..."
|
||||
if rigged daemon start; then
|
||||
if rig daemon start; then
|
||||
sleep 2
|
||||
else
|
||||
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
|
||||
if rigged ps --json >/dev/null 2>&1; then
|
||||
if rig ps --json >/dev/null 2>&1; then
|
||||
echo "Reusing existing healthy daemon."
|
||||
else
|
||||
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
|
||||
@@ -21,18 +21,18 @@ fi
|
||||
# Boot the demo topology
|
||||
echo ""
|
||||
echo "Booting demo topology..."
|
||||
UP_OUTPUT=$(rigged up demo/rig.yaml)
|
||||
UP_OUTPUT=$(rig up demo/rig.yaml)
|
||||
printf '%s\n' "$UP_OUTPUT"
|
||||
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
|
||||
if [ -z "$RIG_ID" ]; then
|
||||
echo "ERROR: failed to determine rig ID from 'rigged up' output." >&2
|
||||
echo "ERROR: failed to determine rig ID from 'rig up' output." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show node status
|
||||
echo ""
|
||||
echo "Node status:"
|
||||
rigged ps --nodes
|
||||
rig ps --nodes
|
||||
|
||||
# Establish a restore-safe baseline for the demo rig.
|
||||
echo ""
|
||||
@@ -55,6 +55,6 @@ echo "=== Demo topology is running ==="
|
||||
echo "Dashboard: http://localhost:5173"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " rigged ps --nodes # Check node status"
|
||||
echo " rigged down $RIG_ID # Tear down (auto-snapshots)"
|
||||
echo " rigged restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
|
||||
echo " rig ps --nodes # Check node status"
|
||||
echo " rig down $RIG_ID # Tear down (auto-snapshots)"
|
||||
echo " rig restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
|
||||
@@ -59,12 +59,12 @@ export function runCommand(command: string, args: string[], cwd = repoRoot()): s
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export function runRigged(args: string[]): string {
|
||||
return runCommand("rigged", args);
|
||||
export function runRig(args: string[]): string {
|
||||
return runCommand("rig", args);
|
||||
}
|
||||
|
||||
export function runRiggedJson<T>(args: string[]): T {
|
||||
const output = runRigged(args);
|
||||
export function runRigJson<T>(args: string[]): T {
|
||||
const output = runRig(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
export function listRigSummaries(): DemoRigSummary[] {
|
||||
return runRiggedJson<DemoRigSummary[]>(["ps", "--json"]);
|
||||
return runRigJson<DemoRigSummary[]>(["ps", "--json"]);
|
||||
}
|
||||
|
||||
export function getCurrentRigSummary(rigNameOrId: string): DemoRigSummary | null {
|
||||
@@ -95,7 +95,7 @@ export function listRigNodes(rigNameOrId: string): DemoNodeEntry[] {
|
||||
return [];
|
||||
}
|
||||
return filterNodesForRigId(
|
||||
runRiggedJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
|
||||
runRigJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
|
||||
summary.rigId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function probeNodeResume(
|
||||
const pollMs = opts.pollMs ?? 500;
|
||||
const maxWaitMs = opts.maxWaitMs ?? 6_000;
|
||||
const attempts = Math.max(1, Math.floor(maxWaitMs / pollMs));
|
||||
const sessionName = `rigged-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
|
||||
const sessionName = `rig-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
runTmux(["new-session", "-d", "-s", sessionName, "-c", resolveNodeCwd(node.cwd ?? ".")]);
|
||||
|
||||
@@ -100,7 +100,7 @@ function sendWarmup(sessionName: string | null, text: string): void {
|
||||
}
|
||||
|
||||
execFileSync(
|
||||
"rigged",
|
||||
"rig",
|
||||
["send", sessionName, text, "--verify", "--force", "--json"],
|
||||
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
|
||||
+317
-82
@@ -1,16 +1,16 @@
|
||||
# OpenRig — As-Built Architecture
|
||||
## Final Stretch 1 Snapshot (as of 2026-04-05)
|
||||
## Current Architecture
|
||||
|
||||
Status:
|
||||
- Four implementation rounds have landed: AgentSpec reboot (16 tasks), North Star (16 tasks), Post-North-Star (11 tasks + dogfood fixes + UI polish), and Final Stretch 1 (spec library, `whoami`, adopted-session parity, specs/discovery drawer workflows, reusable spec display surfaces, live-node full-details route, graph hover/runtime hints).
|
||||
- Shipped source footprint: `253` source files across the three packages.
|
||||
- Daemon footprint: `133` source files total, including `79` domain files, `21` route files, `10` adapters, and `17` migrations.
|
||||
- CLI footprint: `37` source files.
|
||||
- UI footprint: `83` source files.
|
||||
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
|
||||
- Shipped source footprint: `286` source files across the three packages.
|
||||
- Daemon footprint: `150` source files total, including `90` domain files, `22` route files, `11` adapters, and `20` migrations.
|
||||
- CLI footprint: `49` source files.
|
||||
- UI footprint: `87` source files.
|
||||
- Current full-suite verification during this refresh:
|
||||
- daemon: `112/112` files, `1461/1461` passing
|
||||
- CLI: `29/29` files, `275/275` passing
|
||||
- UI: `35` files, `361/364` passing (`3` known design/foundation failures)
|
||||
- daemon: `127/127` files, `1668/1668` passing
|
||||
- CLI: `37/37` files, `366/366` passing
|
||||
- UI: `37/37` files, `388/388` passing
|
||||
|
||||
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
|
||||
|
||||
@@ -18,42 +18,47 @@ Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The system currently has four architectural layers:
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The system currently has six architectural layers:
|
||||
|
||||
1. **AgentSpec / pod-aware core**: spec parsing, resolution, precedence, startup orchestration, snapshot/restore, bundles.
|
||||
2. **North Star operator layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
|
||||
3. **Post-North-Star transport/history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
|
||||
4. **Final Stretch 1 authoring/identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
|
||||
2. **Operator and topology layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
|
||||
3. **Communication and history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
|
||||
4. **Authoring and identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
|
||||
5. **Rig environment layer**: rig-scoped services records, Compose-backed service orchestration, readiness gates, env snapshot/restore integration, runtime env status/logs/down surfaces.
|
||||
6. **Agent-managed software layer**: managed-app classification in the library, app-focused browse/review/runtime UI, managed-app-aware CLI/help surfaces, and the canonical `secrets-manager` + `vault.specialist` example.
|
||||
|
||||
Legacy flat-node/package flows remain for backward compatibility.
|
||||
|
||||
The stack is:
|
||||
|
||||
```text
|
||||
CLI (31 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (21 route groups + dedicated health/export/static handlers)
|
||||
Hono daemon routes (22 route groups + dedicated health/export/static handlers)
|
||||
|
|
||||
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
|
||||
+-- env routes (status / logs / down)
|
||||
+-- transport routes (send/capture/broadcast)
|
||||
+-- transcript routes (tail/grep)
|
||||
+-- ask routes (context evidence packs)
|
||||
+-- chat routes (durable rig messaging + SSE)
|
||||
+-- spec review/library routes
|
||||
+-- whoami identity route
|
||||
+-- spec review/library routes (managed-app enrichment + compose preview)
|
||||
+-- whoami identity + context-usage route
|
||||
|
|
||||
v
|
||||
Framework-free domain services
|
||||
|
|
||||
+-- SQLite state (17 migrations)
|
||||
+-- SQLite state (20 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
|
||||
+-- transport layer (SessionTransport)
|
||||
+-- transcript store (pipe-pane backed)
|
||||
+-- chat repository (SQLite backed)
|
||||
+-- spec review service
|
||||
+-- spec library service
|
||||
+-- spec review service (services metadata + compose preview seam)
|
||||
+-- spec library service (`hasServices` classification)
|
||||
+-- context-usage store / monitor
|
||||
+-- whoami identity service
|
||||
|
||||
The core product loop:
|
||||
@@ -66,7 +71,7 @@ The core product loop:
|
||||
|
||||
### HTTP routes
|
||||
|
||||
`createApp()` now mounts 21 route groups plus:
|
||||
`createApp()` now mounts 22 route groups plus:
|
||||
- `GET /healthz`
|
||||
- `GET /api/rigs/:rigId/spec`
|
||||
- `GET /api/rigs/:rigId/spec.json`
|
||||
@@ -74,7 +79,7 @@ The core product loop:
|
||||
|
||||
In addition to the reboot-era routes:
|
||||
|
||||
**North Star additions:**
|
||||
**Operator and topology routes:**
|
||||
- `GET /api/rigs/:rigId/nodes` — canonical shared node-inventory projection
|
||||
- `GET /api/rigs/:rigId/nodes/:logicalId` — rich node-detail payload for the drawer
|
||||
- `POST /api/rigs/:rigId/nodes/:logicalId/launch` — manual node launch
|
||||
@@ -82,7 +87,7 @@ In addition to the reboot-era routes:
|
||||
- `POST /api/rigs/:id/up` — existing-rig restore by rig ID
|
||||
- `POST /api/discovery/draft-rig` — generate candidate rig spec from discovered sessions
|
||||
|
||||
**Final Stretch 1 additions:**
|
||||
**Authoring and identity routes:**
|
||||
- `POST /api/rigs/import/materialize` — create pod-aware rig topology without launching
|
||||
- `POST /api/discovery/:id/bind` — bind discovered session to an existing logical node
|
||||
- `POST /api/discovery/:id/adopt` — UI-friendly composite adopt route (`bind` or `create_and_bind`)
|
||||
@@ -90,7 +95,12 @@ In addition to the reboot-era routes:
|
||||
- `/api/specs/library` — list/get/review/sync filesystem-backed library entries
|
||||
- `/api/whoami` — daemon-backed runtime identity projection by `nodeId` or `sessionName`
|
||||
|
||||
**Post-North-Star additions:**
|
||||
**Rig environment routes:**
|
||||
- `GET /api/rigs/:rigId/env` — live rig-environment status with best-effort fresh receipt capture
|
||||
- `GET /api/rigs/:rigId/env/logs` — service logs from the Compose adapter
|
||||
- `POST /api/rigs/:rigId/env/down` — explicit env teardown for service-backed rigs
|
||||
|
||||
**Communication and history routes:**
|
||||
- `/api/transport` — `POST /send`, `/capture`, `/broadcast` — communication primitives
|
||||
- `/api/transcripts` — `GET /:session/tail`, `/:session/grep` — transcript access
|
||||
- `/api/ask` — `POST /` — context evidence pack over rig summary + transcripts + chat
|
||||
@@ -103,30 +113,44 @@ Important route behaviors:
|
||||
- `/api/transport/send` classifies target activity before sending and can refuse unless forced
|
||||
- `/api/ask` returns context/evidence, not LLM-synthesized answers
|
||||
- `/api/rigs/:rigId/chat` streams via SSE for real-time delivery
|
||||
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`)
|
||||
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`), managed-app services metadata, and best-effort compose preview
|
||||
- `/api/whoami` fails honestly on missing/ambiguous identity instead of fabricating “healthy” state
|
||||
- `/api/rigs/:rigId/env` returns `{ ok: true, hasServices: false }` honestly when a rig has no service substrate
|
||||
- `/api/rigs/:rigId/env/down` currently honors stored down policy; explicit volume-removal override is not fully wired through yet
|
||||
|
||||
### CLI commands
|
||||
|
||||
`index.ts` currently mounts 31 command groups. Reboot-era commands plus:
|
||||
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
|
||||
|
||||
**North Star additions:**
|
||||
**Operator and topology commands:**
|
||||
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
|
||||
- `rig down` — auto-snapshots before teardown, handoff includes restore command
|
||||
- `rig discover --draft` — generate candidate rig spec from discovered sessions
|
||||
- Changed: `rig ps --nodes` shows per-node detail with session names, status, startup status
|
||||
|
||||
**Final Stretch 1 additions:**
|
||||
**Authoring and identity commands:**
|
||||
- `rig specs ls/show/preview/add/sync` — agent-facing spec library browse/review workflow
|
||||
- `rig whoami --json` — daemon-backed runtime identity with peers, edges, transcript helpers, and command hints
|
||||
- `rig bind <discoveredId> <rigId> <logicalId>` — bind discovered session to an existing node
|
||||
- `rig adopt <file> --bind logicalId=session` — materialize then bind live sessions into a rig
|
||||
- Changed: `rig up <source>` and `rig bootstrap <source>` now resolve spec-library names and fail loudly on rig-name vs library-name ambiguity
|
||||
|
||||
**Post-North-Star additions (8 new command groups):**
|
||||
- `rig send <session> "message" [--verify] [--force]` — send to agent terminal
|
||||
- `rig capture <session> [--lines N] [--json]` — capture agent pane content
|
||||
**Rig environment commands:**
|
||||
- `rig env status <rig>` — inspect rig-scoped environment status for service-backed rigs and managed apps
|
||||
- `rig env logs <rig> [service]` — fetch service logs
|
||||
- `rig env down <rig>` — tear down the rig environment
|
||||
|
||||
**Managed-app-aware help and copy:**
|
||||
- `rig up` help/examples explicitly include library-backed managed apps like `secrets-manager`
|
||||
- `rig specs` help/list/preview text now distinguishes rigs, agents, and managed apps
|
||||
- `openrig-user` now teaches the shipped managed-app model (`software + specialist`) and cross-rig specialist interaction
|
||||
|
||||
**Communication and history commands:**
|
||||
- `rig send <session> "message" [--verify] [--force] [--host <id>]` — send to agent terminal; `--host` routes to a host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). SSH success is NOT verify success — the remote rig's verify result is surfaced verbatim.
|
||||
- `rig capture <session> [--lines N] [--json] [--host <id>]` — capture agent pane content; `--host` same shape as `rig send --host`.
|
||||
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
|
||||
|
||||
**Session source declaration:** Member YAML may carry `session_source` to control how a new managed seat derives its starting context. v1 supports two modes: `fork` (start from a prior native runtime conversation; captures and persists a NEW post-fork token, never the parent — see cli-reference.md "Session source declaration"); and `rebuild` (fresh-launch seeded with operator-declared artifacts in trust-precedence order; the seat's `continuityOutcome` is `rebuilt` and no `resumeToken` is persisted). Both modes flow through the existing rig spec and `rig expand` member-input shapes; no new top-level command was introduced for either.
|
||||
- `rig transcript <session> --tail N / --grep "pattern" [--json]` — transcript access
|
||||
- `rig config [get <key> / set <key> <value> / reset <key>] [--json]` — configuration surface
|
||||
- `rig preflight [--json]` — system readiness check (Node, tmux, writable dirs, port)
|
||||
@@ -139,7 +163,7 @@ The CLI-hosted MCP server exposes 17 tools:
|
||||
|
||||
1-12. Reboot-era: `rigged_up`, `rigged_down`, `rigged_ps`, `rigged_status`, `rigged_snapshot_create`, `rigged_snapshot_list`, `rigged_restore`, `rigged_discover`, `rigged_claim`, `rigged_bundle_inspect`, `rigged_agent_validate`, `rigged_rig_validate`
|
||||
|
||||
13-17. Post-North-Star additions:
|
||||
13-17. Communication and operator additions:
|
||||
- `rigged_rig_nodes` — node inventory for a rig (agents can look up infrastructure sessions)
|
||||
- `rigged_send` — send message to agent terminal
|
||||
- `rigged_capture` — capture agent pane content
|
||||
@@ -157,13 +181,21 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
|
||||
- The shared drawer has five primary surfaces:
|
||||
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
|
||||
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
|
||||
- **Specs drawer:** library list, draft/spec review routing, and spec authoring entry points
|
||||
- **Specs drawer:** unified library list with `All / Apps / Rigs / Agents` filters, type/stability badges, draft/spec review routing, and spec authoring entry points
|
||||
- **Discovery drawer:** placement-first adopt/bind/materialize flow
|
||||
- **System drawer:** global event/activity surface replacing the old status-bar role
|
||||
- Pod selection opens the rig drawer with pod section expanded (no separate pod drawer)
|
||||
- Human-readable IDs are UI-only: pod labels instead of ULIDs in explorer, short ULID tails for glanceability, full IDs in detail views
|
||||
- Infrastructure/terminal nodes have distinct visual treatment
|
||||
- Graph shows pod grouping via React Flow group nodes, status colors (green=ready, amber=launching, red=failed, gray=stopped), and lightweight hover/runtime hints
|
||||
- Service-backed library entries render as `APP` rows instead of generic rigs
|
||||
- `LibraryReview` for a managed app shows:
|
||||
- `Library — Managed App`
|
||||
- `Copy Setup Prompt`
|
||||
- a `Specialist Agent` card (canonical example: `vault.specialist`)
|
||||
- a library-only `Environment` tab with compose preview, health gates, and surfaces
|
||||
- The rig drawer now has `Info`, `Env`, and `Chat Room` tabs for service-backed rigs
|
||||
- `RigEnvPanel` exposes overall env state, per-service health, health gates, surfaces, logs, and explicit env teardown
|
||||
- Explicit center-workspace routes now exist for:
|
||||
- `/specs`
|
||||
- `/specs/rig`
|
||||
@@ -175,7 +207,7 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
The daemon now has 17 migrations.
|
||||
The daemon now has 27 migrations (22 existing plus 5 added by PL-004 Phase A).
|
||||
|
||||
### Core state tables
|
||||
|
||||
@@ -199,6 +231,9 @@ The daemon now has 17 migrations.
|
||||
|
||||
**bindings**
|
||||
- Physical surface attachment: tmux/cmux coordinates.
|
||||
- `019_external_cli_attachment.ts` extends this row shape with:
|
||||
- `attachment_type`
|
||||
- `external_session_name`
|
||||
|
||||
**sessions**
|
||||
- Live execution state.
|
||||
@@ -240,7 +275,7 @@ These remain active:
|
||||
- `label`
|
||||
- `summary`
|
||||
- serialized continuity policy
|
||||
- Final Stretch 1 made `namespace` first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
|
||||
- `namespace` is first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
|
||||
|
||||
**continuity_state** (`014_agentspec_reboot.ts`)
|
||||
- Live per-`pod_id` / `node_id` operational continuity state.
|
||||
@@ -263,11 +298,80 @@ These remain active:
|
||||
- Indexed by `(rig_id, created_at)` for fast rig-scoped queries
|
||||
- Note: transcript persistence is filesystem-backed (pipe-pane → log files), not SQLite. Chat is SQLite-backed.
|
||||
|
||||
**context_usage** (`018_context_usage.ts`)
|
||||
- Persisted per-node context usage snapshots for runtime/operator surfaces.
|
||||
- Used by the daemon-owned context monitor and `whoami`/inventory projections.
|
||||
|
||||
**rig_services** (`020_rig_services.ts`)
|
||||
- Persisted rig-scoped environment record for service-backed rigs.
|
||||
- Stores:
|
||||
- `rig_id`
|
||||
- `kind`
|
||||
- `spec_json`
|
||||
- `rig_root`
|
||||
- resolved `compose_file`
|
||||
- resolved `project_name`
|
||||
- `latest_receipt_json`
|
||||
- timestamps
|
||||
|
||||
Migration boundary:
|
||||
- `014_agentspec_reboot.ts` adds the reboot schema shape.
|
||||
- `015_startup_context.ts` adds persisted startup replay context.
|
||||
- `016_chat_messages.ts` adds durable rig chat.
|
||||
- `017_pod_namespace.ts` backfills/persists authored pod namespace for export, adoption, and identity parity.
|
||||
- `018_context_usage.ts` adds context-usage persistence.
|
||||
- `019_external_cli_attachment.ts` extends binding rows for external CLI attachment.
|
||||
- `020_rig_services.ts` adds rig-scoped services persistence.
|
||||
- `021_seat_handover_observability.ts` adds seat-handover observability fields.
|
||||
- `022_node_codex_config_profile.ts` adds Codex config-profile field on nodes.
|
||||
- `023_stream_items.ts` through `027_outbox_entries.ts` add the PL-004 Phase A coordination tables (see § Coordination Layer below).
|
||||
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
|
||||
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
|
||||
|
||||
### Workflow Runtime (PL-004 Phase D)
|
||||
|
||||
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. Per PRD § L4 and founder direction (2026-04-22): owner-as-author semantically + workflow-as-transactional-scribe mechanically.
|
||||
|
||||
`workflow_specs` is a read-through cache of human-authored markdown/YAML spec files. Sources are workspace-surface; the daemon caches by `(name, version)` with a content `source_hash` so that valid operator edits to the spec file win at next read (workspace-surface reconciliation). Spec authoring stays markdown-authoritative; `workflow_specs` exists for fast lookup and runtime resolution.
|
||||
|
||||
`workflow_instances` carries live state per running workflow: `status` (`active|waiting|completed|failed`), `current_frontier_json` (active qitem ids), `hop_count` (loop-guard counter), and `last_continuation_decision_json` (most recent close/continue decision). Instances survive daemon restart from SQLite — no filesystem reconciliation required.
|
||||
|
||||
`workflow_step_trails` is the append-only history of meaningful step transitions. Every closure produces one trail row pairing the prior qitem with the next qitem (null on terminal). Append-only at the API surface: `WorkflowStepTrailLog.record()` is the only writer.
|
||||
|
||||
The transactional-scribe contract — the load-bearing Phase D guarantee — is implemented in `WorkflowProjector.project()`. A single `db.transaction` holds: closing the current packet (state mutation on `queue_items`), creating the next-step packet (`QueueRepository.createWithinTransaction()`), recording the trail entry, updating the instance frontier + status, and persisting workflow events. Either everything commits or everything rolls back; lost handoffs are impossible by design. Post-commit, subscribers are notified and the next owner is nudged.
|
||||
|
||||
`workflow-keepalive` (the Phase C-deferred watchdog policy) reads `workflow_instances` directly via SQLite — never the markdown source. Eligible only when `status` is `active|waiting`; resolves frontier qitem owners by querying `queue_items`; combines with explicit observer/created_by targets; sends to the first resolved target.
|
||||
|
||||
Phase D extends `RigEvent` with: `workflow.instantiated`, `workflow.step_closed`, `workflow.next_qitem_projected`, `workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`. Existing 20 PL-004 events are unchanged.
|
||||
|
||||
Phase D scope intentionally excludes multi-hop chaining (graduation per PRD § Risks "Workflow runtime over-engineered"), gate-return-sweep, and the closure-enforcement path (the daemon's transactional state remains the closure authority via Phase A's hot-potato strict-rejection; workflow runtime PROJECTS on closure, does not GATE closure). Phase E (progress-rail) remains markdown-authoritative for v1.
|
||||
|
||||
### Coordination tables (PL-004 Phase A)
|
||||
|
||||
Five host-scoped tables back the coordination primitive:
|
||||
|
||||
**stream_items** (`023_stream_items.ts`)
|
||||
- L1 — append-only intake/audit root.
|
||||
- Columns: `stream_item_id` (ULID PK), `ts_emitted`, `stream_sort_key`, `source_session`, `body`, `format` (default `text`), `hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON), `interrupt`, `archived_at`.
|
||||
- Items immutable after emit (only `archived_at` may be set).
|
||||
|
||||
**queue_items** (`024_queue_items.ts`)
|
||||
- L3 — owned-work queue.
|
||||
- Columns: `qitem_id` (TEXT PK preserving POC `qitem-YYYYMMDDHHMMSS-<hex>` shape), `ts_created`, `ts_updated`, `source_session`, `destination_session`, `state` (8-value enum), `priority`, `tier`, `tags` (JSON), `blocked_on`, `handed_off_to`, `handed_off_from`, `expires_at`, `chain_of_record` (JSON), `body`, `closure_reason`, `closure_target`, `closure_required_at`, `claimed_at`, `last_nudge_attempt`, `last_nudge_result`, `last_heartbeat`, `resolution`.
|
||||
- State enum: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`.
|
||||
|
||||
**queue_transitions** (`025_queue_transitions.ts`)
|
||||
- L3 — append-only transition log. Authoritative audit trail for queue state evolution. No UPDATE / DELETE from domain code.
|
||||
- Columns: `transition_id` (AUTOINCREMENT PK), `qitem_id`, `ts`, `state`, `transition_note`, `actor_session`, `closure_reason`, `closure_target`.
|
||||
|
||||
**inbox_entries** (`026_inbox_entries.ts`)
|
||||
- Mailbox-style asynchronous deposit; idempotent on `inbox_id`.
|
||||
- Columns: `inbox_id` (PK), `destination_session`, `sender_session` (authenticated), `body`, `tags` (JSON), `urgency`, `ts_dropped`, `state` (`pending | absorbed | denied`), `absorbed_at`, `absorbed_qitem_id`, `denied_at`, `denied_reason`, `audit_pointer`.
|
||||
|
||||
**outbox_entries** (`027_outbox_entries.ts`)
|
||||
- Sender-side audit; symmetric to inbox; idempotent on `outbox_id`.
|
||||
- Columns: `outbox_id` (PK), `sender_session`, `destination_session`, `body`, `tags` (JSON), `urgency`, `ts_dispatched`, `delivery_state` (`pending | delivered | failed`), `delivered_at`, `audit_pointer`.
|
||||
|
||||
---
|
||||
|
||||
@@ -284,6 +388,17 @@ Migration boundary:
|
||||
- Uses `version: "0.2"` and `pods[]`.
|
||||
- Owns cross-pod `edges[]`, rig-level startup overlays, and `cultureFile`.
|
||||
|
||||
**RigServicesSpec**
|
||||
- Optional `services` block on a pod-aware `RigSpec`.
|
||||
- Current shipped service kind is Compose-backed env management with:
|
||||
- `composeFile`
|
||||
- `projectName?`
|
||||
- `profiles?`
|
||||
- `downPolicy?`
|
||||
- `waitFor?`
|
||||
- `surfaces?`
|
||||
- `checkpoints?`
|
||||
|
||||
**RigSpecPod**
|
||||
- Pod-local bounded context with `members[]`, pod-local `edges[]`, pod startup, and optional continuity policy.
|
||||
|
||||
@@ -329,13 +444,21 @@ Migration boundary:
|
||||
- Pre-launch vs interactive delivery split: `guidance_merge`/`skill_install` happen before harness boot (filesystem); `send_text` happens after harness is ready (TUI).
|
||||
- Persists replay context including resume token for future restores.
|
||||
|
||||
### Operator-layer types (North Star + Post-North-Star)
|
||||
**EnvReceipt**
|
||||
- RigEnv capture result persisted for service-backed rigs.
|
||||
- Carries service status/health plus evaluated wait targets and capture timestamp.
|
||||
|
||||
**RigServicesRecord**
|
||||
- Persisted database representation of a rig-scoped services substrate.
|
||||
- Holds resolved compose path, persisted spec JSON, project name, and latest receipt JSON.
|
||||
|
||||
### Operator, identity, and communication
|
||||
|
||||
**NodeInventoryEntry**
|
||||
- Universal node projection consumed by CLI/UI/MCP: rigId, rigName, logicalId, canonicalSessionName, podId, nodeKind (agent/infrastructure), runtime, sessionStatus, startupStatus, restoreOutcome, tmuxAttachCommand, resumeCommand, latestError.
|
||||
|
||||
**NodeDetailEntry**
|
||||
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus Final Stretch 1 live-identity fields:
|
||||
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus live-identity fields:
|
||||
- `peers`
|
||||
- directional `edges`
|
||||
- transcript helpers/path
|
||||
@@ -396,7 +519,7 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
|
||||
- `startup-validation.ts`: shared startup block validation
|
||||
- `path-safety.ts`: shared relative-path safety checks
|
||||
- `spec-validation-service.ts`: pure raw-YAML validation helpers
|
||||
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data and provenance state
|
||||
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data, provenance state, and managed-app services metadata (`waitFor`, `surfaces`, `composePreview`)
|
||||
|
||||
### Resolution pipeline
|
||||
|
||||
@@ -415,6 +538,15 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
|
||||
- `rigspec-exporter.ts`: dual-format live rig export back to YAML/JSON
|
||||
- `pod-repository.ts`: pod CRUD plus live continuity-state CRUD
|
||||
|
||||
### Rig environment and agent-managed software
|
||||
|
||||
- `compose-services-adapter.ts`: thin Compose-backed adapter for `up`, `ps`, `logs`, and teardown
|
||||
- `services-readiness.ts`: shared wait-target evaluator for `url`, `tcp`, and `service + condition: healthy`
|
||||
- `service-orchestrator.ts`: rig-scoped env boot, receipt capture, readiness gating, persisted receipt updates, and teardown
|
||||
- `rig-repository.ts`: now persists `rig_services` rows and surfaces `hasServices` in rig summaries for runtime/UI gating
|
||||
- `spec-library-service.ts`: classifies builtin/user specs and marks service-backed rigs with `hasServices`
|
||||
- `spec-review-service.ts`: lifts services metadata into review models; route layer enriches with compose preview
|
||||
|
||||
### Runtime adapters
|
||||
|
||||
Three runtime adapters implement the five-method contract: projection, startup delivery, harness launch, readiness, and installed-resource listing.
|
||||
@@ -429,18 +561,20 @@ Three runtime adapters implement the five-method contract: projection, startup d
|
||||
- launches via `codex`, resumes via `codex resume <threadId>`
|
||||
- readiness probe: polls for Codex ready indicator
|
||||
|
||||
**TerminalAdapter** (North Star)
|
||||
**TerminalAdapter**
|
||||
- no-op project/deliver/launch (shell IS the harness)
|
||||
- immediate readiness (shell is ready as soon as tmux session exists)
|
||||
- for infrastructure nodes: servers, log tails, build watchers
|
||||
|
||||
### Node inventory and operator surfaces (North Star)
|
||||
### Node inventory and operator surfaces
|
||||
|
||||
- `node-inventory.ts`: universal node-level projection. The single source of truth for node state consumed by CLI (`ps --nodes`), UI (explorer + graph + drawer), and MCP (`rigged_rig_nodes`). Core + extended field tiers.
|
||||
- `demo-rig-selector.ts`: existing-rig power-on helper — finds the right rig by name from ps summary
|
||||
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows
|
||||
- `context-usage-store.ts`: SQLite + state-dir persistence for per-node context usage
|
||||
- `context-monitor.ts`: daemon-owned monitor that records context usage for runtime/operator surfaces
|
||||
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows, now with context-usage access
|
||||
|
||||
### Transport and communication (Post-North-Star)
|
||||
### Transport and communication
|
||||
|
||||
- `session-transport.ts`: communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection, honest error reporting, pod/rig/global targeting
|
||||
- `transcript-store.ts`: pipe-pane transcript management — ANSI stripping on read, boundary markers, readTail, grep. Filesystem-backed, not SQLite.
|
||||
@@ -448,29 +582,40 @@ Three runtime adapters implement the five-method contract: projection, startup d
|
||||
- `ask-service.ts`: context engineering evidence pack — gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance. Does NOT call an external LLM.
|
||||
- `chat-repository.ts`: durable rig-scoped chat — CRUD for chat_messages table, SSE-compatible event emission
|
||||
|
||||
### Resume honesty (North Star)
|
||||
### Coordination primitive (PL-004 Phase A)
|
||||
|
||||
Six host-scoped services implementing the SQLite-canonical coordination layer. POC `rigx queue` / `rigx stream` filesystem path remains untouched; daemon `rig queue` / `rig stream` writes only to SQLite. Routes import these; services are Hono-free.
|
||||
|
||||
- `stream-store.ts`: L1 stream — idempotent emit (on `stream_item_id`), chronological list with cursor pagination, archive (soft).
|
||||
- `queue-repository.ts`: L3 queue — create, claim/unclaim, update (general state mutator with hot-potato strict-rejection on `done`), transactional handoff (close source as `handed-off` plus create new owned qitem, single transaction), pod-fallback rerouting, overdue lookup, nudge/heartbeat tracking. Cross-rig validation hook exposed as `validateRig` constructor option.
|
||||
- `queue-transition-log.ts`: append-only state transition log. Used by `queue-repository.ts` and exposed read-only on `queue_repository.transitionLog`.
|
||||
- `hot-potato-enforcer.ts`: pure validator for the load-bearing API contract — `state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation}`; `handed_off_to | blocked_on | escalation` additionally require `closure_target`. Tier→SLA mapping for `closure_required_at` lives here.
|
||||
- `inbox-handler.ts`: mailbox handler — authenticated drop (idempotent on `inbox_id`), absorb (promotes pending entry to a queue_item, idempotent), deny (records reason). Auth check is a pluggable constructor hook.
|
||||
- `outbox-handler.ts`: sender-side outbox; idempotent record, mark delivered/failed, list. No event-bus events emitted (pure audit).
|
||||
|
||||
### Resume honesty
|
||||
|
||||
- `native-resume-probe.ts`: honest assessment of whether a harness actually resumed vs fresh-launched — probes pane content for runtime-specific indicators
|
||||
- `resume-metadata-refresher.ts`: post-launch resume token capture — reads Claude conversation IDs from `.claude/` state, Codex thread IDs from SQLite state files
|
||||
- `codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite database
|
||||
|
||||
### Discovery extensions (North Star)
|
||||
### Discovery extensions
|
||||
|
||||
- `draft-rig-generator.ts`: synthesize a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs
|
||||
- `claim-service.ts`: adopted-session integration service — claim/bind/create-and-bind flows, tmux metadata writes, and post-claim onboarding hint
|
||||
|
||||
### Final Stretch 1 authoring and library surfaces
|
||||
### Authoring, library, and identity surfaces
|
||||
|
||||
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review
|
||||
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, and reusable display components
|
||||
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review and `hasServices`
|
||||
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, reusable display components, and managed-app env details
|
||||
- `whoami-service.ts`: daemon-side identity surface with peers/edges/transcript helpers
|
||||
|
||||
### Snapshot, restore, and continuity
|
||||
|
||||
- `checkpoint-store.ts`: checkpoint persistence with pod/continuity context
|
||||
- `snapshot-capture.ts`: captures pods, continuity state, and startup replay context
|
||||
- `snapshot-capture.ts`: captures pods, continuity state, startup replay context, and latest env receipt
|
||||
- `snapshot-repository.ts`: snapshot CRUD
|
||||
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, and topology ordering
|
||||
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, topology ordering, and RigEnv boot gating before agent restore
|
||||
|
||||
### Bundles, bootstrap, and legacy compatibility
|
||||
|
||||
@@ -551,14 +696,14 @@ Restore now:
|
||||
- restore states: `resumed` / `rebuilt` / `fresh` / `failed`
|
||||
- failed resume is FAILED loudly — no automatic fresh fallback
|
||||
|
||||
### Auto-snapshot + existing-rig power-on (North Star)
|
||||
### Auto-snapshot and existing-rig power-on
|
||||
|
||||
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before teardown
|
||||
- `rig up <rig-name>` (no file extension) searches for existing rig by name, restores from latest auto-pre-down snapshot
|
||||
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot from a spec or bundle path.")
|
||||
- Post-command handoff: down output includes snapshot ID + restore command; up output includes node statuses + attach command
|
||||
|
||||
### Communication flow (Post-North-Star)
|
||||
### Communication flow
|
||||
|
||||
`rig send <session> "message"` → CLI → `POST /api/transport/send` → `SessionTransport`:
|
||||
1. Resolve session name (canonical or legacy, by session/rig/pod/global)
|
||||
@@ -567,7 +712,7 @@ Restore now:
|
||||
4. Optional `--verify`: capture post-send pane, check message visibility
|
||||
5. Honest result with reason on failure
|
||||
|
||||
### Transcript flow (Post-North-Star)
|
||||
### Transcript flow
|
||||
|
||||
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation (before harness boot)
|
||||
2. Raw terminal output streams to `~/.openrig/transcripts/{rig-name}/{session-name}.log`
|
||||
@@ -576,7 +721,7 @@ Restore now:
|
||||
5. On restore: boundary marker written before re-launch. Pipe-pane reconnects to same file (append).
|
||||
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance
|
||||
|
||||
### Chat flow (Post-North-Star)
|
||||
### Chat flow
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST /api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`
|
||||
2. SSE stream: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages
|
||||
@@ -585,7 +730,7 @@ Restore now:
|
||||
5. MCP: `rigged_chatroom_send` + `rigged_chatroom_watch`
|
||||
6. Source of truth: daemon-backed SQLite (`chat_messages` table), not tmux scrollback
|
||||
|
||||
### Config + preflight flow (Post-North-Star)
|
||||
### Config and preflight flow
|
||||
|
||||
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
|
||||
- Precedence: CLI flag > env var > config file > default
|
||||
@@ -593,11 +738,11 @@ Restore now:
|
||||
- Auto-preflight runs on `rig up` and daemon start
|
||||
- Every preflight error: what failed + why it matters + what to do (3-part pattern)
|
||||
|
||||
### Discovery-to-draft-rig flow (North Star)
|
||||
### Discovery-to-draft-rig flow
|
||||
|
||||
`rig discover --draft` → scan tmux sessions → group by CWD → suggest pod structure → generate candidate RigSpec YAML → output to stdout or file
|
||||
|
||||
### Spec review and spec library flow (Final Stretch 1)
|
||||
### Spec review and spec library flow
|
||||
|
||||
1. Raw YAML preview:
|
||||
- UI/CLI posts YAML to `/api/specs/review/rig` or `/api/specs/review/agent`
|
||||
@@ -606,12 +751,18 @@ Restore now:
|
||||
2. Filesystem-backed library:
|
||||
- `SpecLibraryService` scans builtin + user roots (`packages/daemon/specs`, `~/.openrig/specs`) with legacy fallback from `~/.rigged/specs`
|
||||
- each YAML file is classified via structured review
|
||||
- service-backed rigs are marked `hasServices`
|
||||
- `/api/specs/library` serves list/get/review/sync
|
||||
3. CLI:
|
||||
- `rig specs ls/show/preview/add/sync`
|
||||
- `rig specs add` installs either a single YAML spec or a full spec directory containing `rig.yaml` or `agent.yaml`
|
||||
- `rig up` / `rig bootstrap` resolve library names before falling back to other source kinds
|
||||
4. UI:
|
||||
- `Specs` drawer presents a unified library list with `All / Apps / Rigs / Agents`
|
||||
- service-backed entries render as `APP`
|
||||
- library review shows specialist identity, setup prompt, and environment details
|
||||
|
||||
### Whoami and adopted-session parity flow (Final Stretch 1)
|
||||
### Whoami and adopted-session parity flow
|
||||
|
||||
1. Managed sessions still prefer projected `OPENRIG_NODE_ID` / `OPENRIG_SESSION_NAME`.
|
||||
2. Adopted sessions use tmux-owned metadata written at claim/bind time:
|
||||
@@ -628,7 +779,7 @@ Restore now:
|
||||
- raw tmux session-name fallback
|
||||
4. The daemon owns the truth surface through `/api/whoami`; tmux metadata is an adopted-session anchor, not sovereign truth.
|
||||
|
||||
### Materialize / bind / adopt flow (Final Stretch 1)
|
||||
### Materialize / bind / adopt flow
|
||||
|
||||
1. `POST /api/rigs/import/materialize` creates a pod-aware topology without launching sessions.
|
||||
2. `POST /api/discovery/:id/bind` attaches a discovered live session to an existing logical node.
|
||||
@@ -640,7 +791,7 @@ Restore now:
|
||||
- `rig adopt`
|
||||
5. Authored pod namespace is preserved through adoption so logical ids stay `${podNamespace}.${memberName}`.
|
||||
|
||||
### Live identity / specs UI flow (Final Stretch 1)
|
||||
### Live identity / specs UI flow
|
||||
|
||||
1. Explorer/graph selection opens the shared right drawer.
|
||||
2. Node drawer stays runtime-first and now includes:
|
||||
@@ -656,6 +807,48 @@ Restore now:
|
||||
- live full-details surfaces
|
||||
5. Graph nodes carry lightweight hover/runtime hints without replacing drawer-first operation.
|
||||
|
||||
### Rig environment flow
|
||||
|
||||
1. Rig instantiation persists a `rig_services` record when a pod-aware `RigSpec` has a `services` block.
|
||||
2. `ServiceOrchestrator.boot(...)` runs before agent launch/restore:
|
||||
- Compose `up`
|
||||
- evaluate `waitFor`
|
||||
- persist `EnvReceipt`
|
||||
- fail honestly on timeout or unhealthy waits
|
||||
3. `GET /api/rigs/:rigId/env` returns live status:
|
||||
- `hasServices: false` for non-service rigs
|
||||
- otherwise kind, compose identity, receipt, and surfaces
|
||||
4. `rig env status/logs/down` and the rig drawer `Env` tab are thin client surfaces over those routes.
|
||||
5. Snapshot/restore preserves the latest env receipt and reboots services before agent restore.
|
||||
6. **Current runtime truth model:** Runtime env truth is provided through boot-time gating (services must be healthy before agents launch) and on-demand inspection via `rig env` / the `Env` tab, which polls with honest `probeStatus` (fresh/stale/no_orchestrator). Background health monitoring and `rig ps` env health summary are deferred.
|
||||
|
||||
### Agent-managed app flow
|
||||
|
||||
1. A managed app is a service-backed rig plus a specialist agent.
|
||||
2. The canonical shipped example is:
|
||||
- rig: `secrets-manager`
|
||||
- pod: `vault`
|
||||
- member: `specialist`
|
||||
- session: `vault-specialist@secrets-manager`
|
||||
3. Specs/UI flow:
|
||||
- browse `secrets-manager` under `Apps`
|
||||
- open library review
|
||||
- inspect specialist identity, env stack, health gates, and surfaces
|
||||
- copy the setup prompt
|
||||
4. Runtime flow:
|
||||
- launch the rig
|
||||
- services boot before `vault.specialist`
|
||||
- the rig drawer `Env` tab exposes state, health, surfaces, logs, and teardown
|
||||
|
||||
### Specialist interaction flow
|
||||
|
||||
1. Other humans or agents address a specialist by session name, e.g. `vault-specialist@secrets-manager`.
|
||||
2. Cross-rig communication uses the existing transport seam:
|
||||
- `rig send vault-specialist@secrets-manager "..." --verify`
|
||||
3. No new routing primitive exists here:
|
||||
- delegation is currently conventional/social
|
||||
- session transport remains the transport truth
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture Rules
|
||||
@@ -727,6 +920,13 @@ Present in the union but not yet emitted by production code:
|
||||
- `pod.created`, `pod.deleted`
|
||||
- `continuity.sync`, `continuity.degraded`
|
||||
|
||||
PL-004 Phase A adds 9 coordination events emitted by domain services:
|
||||
- `stream.emitted` (StreamStore.emit)
|
||||
- `queue.created`, `queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue` (QueueRepository)
|
||||
- `inbox.absorbed`, `inbox.denied` (InboxHandler)
|
||||
|
||||
Two SSE surfaces stream coordination events: `/api/stream/watch` for new stream items, `/api/queue/watch` for queue/inbox events.
|
||||
|
||||
The event log remains append-only and SQLite-backed. The global SSE stream (`/api/events`) delivers all events; the chat SSE stream (`/api/rigs/:rigId/chat/watch`) delivers chat messages for one rig.
|
||||
|
||||
---
|
||||
@@ -735,7 +935,7 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
|
||||
`createDaemon()` now does the following:
|
||||
|
||||
1. Open SQLite and run all 17 migrations.
|
||||
1. Open SQLite and run all 27 migrations (22 existing plus the 5 PL-004 Phase A coordination tables).
|
||||
2. Construct core repositories and legacy services.
|
||||
3. Construct package/bootstrap/discovery services.
|
||||
4. Construct rebooted startup/runtime services:
|
||||
@@ -743,19 +943,25 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
- `ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`
|
||||
- `PodRigInstantiator`
|
||||
- `PodBundleSourceResolver`
|
||||
5. Construct North Star + Post-North-Star services:
|
||||
5. Construct rig environment services:
|
||||
- `ComposeServicesAdapter`
|
||||
- `ServiceOrchestrator`
|
||||
6. Construct operator, transport, and history services:
|
||||
- `TranscriptStore` (with config from env for transcript path/enabled)
|
||||
- `SessionTransport`
|
||||
- `ChatRepository`
|
||||
- `AskService` (with `HistoryQuery`)
|
||||
- `ResumeMetadataRefresher`
|
||||
- `NodeInventory`
|
||||
6. Construct Final Stretch 1 services:
|
||||
- `ResumeMetadataRefresher`
|
||||
- `ContextUsageStore`
|
||||
- `ContextMonitor`
|
||||
- `NodeInventory`
|
||||
7. Construct authoring, identity, and managed-app services:
|
||||
- `SpecReviewService`
|
||||
- `SpecLibraryService` (builtin + user roots)
|
||||
- `WhoamiService`
|
||||
7. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
8. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including specs review/library and `whoami`.
|
||||
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
9. Construct PL-004 Phase A coordination services from a shared `QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue` write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`, `OutboxHandler`.
|
||||
10. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, `whoami`, plus the PL-004 coordination routes (`/api/stream`, `/api/queue`).
|
||||
|
||||
---
|
||||
|
||||
@@ -763,25 +969,18 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
|
||||
### Test footprint
|
||||
|
||||
- daemon: 112 Vitest files / 1,461 tests
|
||||
- CLI: 29 Vitest files / 275 tests
|
||||
- UI: 35 Vitest files / 364 tests
|
||||
- total: 176 files / 2,100 tests
|
||||
- daemon: 127 Vitest files / 1,668 tests
|
||||
- CLI: 37 Vitest files / 366 tests
|
||||
- UI: 37 Vitest files / 388 tests
|
||||
- total: 201 files / 2,422 tests
|
||||
|
||||
### Verified during this doc refresh
|
||||
|
||||
- daemon: `1461/1461` passing
|
||||
- CLI: `275/275` passing
|
||||
- UI: `361/364` passing
|
||||
- daemon: `1668/1668` passing
|
||||
- CLI: `366/366` passing
|
||||
- UI: `388/388` passing
|
||||
|
||||
Current known UI failures during this refresh:
|
||||
- `design-compliance.test.tsx`
|
||||
- non-zero border-radius count still above zero
|
||||
- stale `StatusBar.tsx` file assumption in the mono/data-display assertion
|
||||
- `tailwind-foundation.test.tsx`
|
||||
- missing `.bg-card` assertion in generated CSS
|
||||
|
||||
### Post-North-Star test suites
|
||||
### Communication, identity, and authoring suites
|
||||
|
||||
In addition to reboot-era coverage:
|
||||
|
||||
@@ -804,9 +1003,23 @@ In addition to reboot-era coverage:
|
||||
- `adopt.test.ts` — CLI materialize/bind adoption flow
|
||||
- UI tests for spec review displays, spec library review, specs panel, live node details, and adopted-session node identity
|
||||
|
||||
### Rig environment and agent-managed software suites
|
||||
|
||||
- `rigspec-services.test.ts` — RigSpec `services` schema and parsing
|
||||
- `service-orchestrator.test.ts` — Compose-backed env boot, readiness, receipt capture, teardown
|
||||
- `services-up.test.ts` — end-to-end service-backed rig boot
|
||||
- `services-snapshot-restore.test.ts` — env receipt capture + restore gating
|
||||
- `env-routes.test.ts` — runtime env status/logs/down route contracts
|
||||
- `spec-library-starters.test.ts` — builtin `secrets-manager` + `vault-specialist` presence, topology, and `hasServices`
|
||||
- `spec-review-service.test.ts` / `spec-library-routes.test.ts` — managed-app services review, wait-target contract, compose preview
|
||||
- CLI `env.test.ts` — env status/logs/down command surface
|
||||
- UI `specs-panel.test.tsx` — `All / Apps / Rigs / Agents`, type/stability badges
|
||||
- UI `library-review.test.tsx` — specialist card, setup prompt, managed-app environment tab
|
||||
- UI `rig-detail-panel.test.tsx` — runtime `Env` tab and env-state rendering
|
||||
|
||||
### Dogfood status
|
||||
|
||||
Post-North-Star + Final Stretch 1 dogfood verified:
|
||||
Core operator, communication, identity, and library flows verified:
|
||||
|
||||
- Full product loop: `rig up demo/rig.yaml` → harnesses launch → `rig ps --nodes` → `rig down` → `rig up demo-rig` (restore by name) → agents resume
|
||||
- Communication: `rig send`, `rig capture`, `rig broadcast` across running rig
|
||||
@@ -820,6 +1033,25 @@ Post-North-Star + Final Stretch 1 dogfood verified:
|
||||
- Adoption: materialize + bind/adopt flows from discovery/specs workflows
|
||||
- MCP: all 17 tools verified
|
||||
|
||||
Rig environment and agent-managed software flows additionally verified:
|
||||
|
||||
- Managed-app browse flow in the UI:
|
||||
- `Specs` → `Apps` → `secrets-manager`
|
||||
- managed-app review with `vault.specialist`
|
||||
- `secrets-manager` launch as a real service-backed rig with a specialist agent
|
||||
- Real Vault operations through `vault-specialist@secrets-manager`
|
||||
- health check
|
||||
- write secret
|
||||
- read secret back
|
||||
- Cross-rig direct messaging to `vault-specialist@secrets-manager`
|
||||
- Runtime `Env` tab:
|
||||
- overall state
|
||||
- health gates
|
||||
- surfaces
|
||||
- logs
|
||||
- explicit teardown
|
||||
- Failure honesty when Vault is stopped mid-rig
|
||||
|
||||
---
|
||||
|
||||
## 11. Remaining Compatibility Notes
|
||||
@@ -834,6 +1066,9 @@ These are the main intentional limits that still describe the shipped system:
|
||||
6. Chat is rig-scoped only — no cross-rig channels or DMs.
|
||||
7. `--verify` on `rig send` checks pane content for message visibility but can produce false positives from pre-existing matching content. Known limitation.
|
||||
8. Terminal node readiness is shell-ready only — no service health probes.
|
||||
9. `rig env down --volumes` exists in the CLI surface, but the explicit daemon-side override is not fully plumbed through yet.
|
||||
10. Managed-app service surfaces are descriptive only — OpenRig does not automatically inject service URLs/tokens into agent prompts beyond authored startup/context files.
|
||||
11. Specialist delegation is conventional, not automatic — other agents/humans address the specialist by session name or other normal communication surfaces.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+384
-18
@@ -1,6 +1,6 @@
|
||||
# OpenRig CLI Reference
|
||||
|
||||
Verified against the shipped CLI on 2026-04-07 using:
|
||||
Verified against the shipped CLI on 2026-05-03 (v0.2.0) using:
|
||||
- `packages/cli/src/index.ts`
|
||||
- `packages/cli/src/commands/*.ts`
|
||||
- `packages/cli/src/mcp-server.ts`
|
||||
@@ -11,9 +11,10 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
## Overview
|
||||
|
||||
- Binary: `rig`
|
||||
- Top-level command groups: `36`
|
||||
- Top-level command groups: `53`
|
||||
- Output mode: human-readable by default; many commands also support `--json`
|
||||
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
|
||||
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
|
||||
- Legacy surface still shipped: `package`
|
||||
|
||||
## Top-Level Commands
|
||||
@@ -31,11 +32,13 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `bootstrap` | Bootstrap a rig from a spec file |
|
||||
| `requirements` | Check requirements for a rig spec |
|
||||
| `discover` | Scan for unmanaged tmux sessions |
|
||||
| `attach` | Attach the current shell or agent into a rig node |
|
||||
| `bind` | Bind a discovered session to a rig node |
|
||||
| `adopt` | Materialize topology and bind discovered live sessions |
|
||||
| `bundle` | Manage rig bundles |
|
||||
| `up` | Bootstrap a rig from a spec or bundle |
|
||||
| `down` | Tear down a rig |
|
||||
| `env` | Inspect and control rig environment services |
|
||||
| `ps` | List rigs and their status |
|
||||
| `mcp` | MCP server for agent integration |
|
||||
| `agent` | Manage agent specs |
|
||||
@@ -51,11 +54,26 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `config` | Inspect and change OpenRig configuration |
|
||||
| `preflight` | Check system readiness for OpenRig |
|
||||
| `doctor` | Verify OpenRig install health |
|
||||
| `destroy` | Destroy OpenRig local state for recovery |
|
||||
| `expand` | Add a pod to a running rig |
|
||||
| `unclaim` | Release an adopted session without killing tmux |
|
||||
| `release` | Release claimed sessions from a rig |
|
||||
| `launch` | Launch or relaunch a node in a running rig |
|
||||
| `remove` | Remove a node from a running rig |
|
||||
| `shrink` | Remove an entire pod from a running rig |
|
||||
| `setup` | Prepare the machine for OpenRig |
|
||||
| `stream` | Coordination L1 — append-only intake stream |
|
||||
| `queue` | Coordination L3 — owned-work queue + inbox/outbox |
|
||||
| `project` | Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim |
|
||||
| `view` | Coordination L5 — daemon-backed views over coordination state |
|
||||
| `watchdog` | Coordination Watchdog — daemon-native scheduler |
|
||||
| `workflow` | Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection |
|
||||
| `restore-packet` | Generate, read, and validate cross-runtime restore packets |
|
||||
| `restore-check` | Check restore readiness across running rigs |
|
||||
| `context` | Show context-usage across running agents |
|
||||
| `compact-plan` | Plan Claude compact-in-place candidates without compacting anything |
|
||||
| `heartbeat` | Show workflow execution proof state from queue files |
|
||||
| `seat` | Inspect OpenRig seat observability state |
|
||||
|
||||
## Core Daemon and System Commands
|
||||
|
||||
@@ -124,7 +142,24 @@ Usage: `rig doctor [--json]`
|
||||
|
||||
Notes:
|
||||
- Verifies install health for packaged/local CLI usage.
|
||||
- Checks daemon dist, UI dist, Node version, tmux availability, writable state paths, and daemon port availability.
|
||||
- Checks daemon dist, UI dist, Node version, `tmux`, optional `cmux` control health, writable state paths, and daemon port availability.
|
||||
- On macOS, also warns when tmux mouse mode appears disabled, gives the current-server fix (`tmux set -g mouse on`), and points to the persistent fix in `~/.tmux.conf`.
|
||||
- `cmux` issues are warnings, not hard failures. OpenRig still works without `cmux`; only `Open CMUX` workflows are unavailable.
|
||||
- `--json` is suitable for agent use and only exits non-zero on real failures, not warnings.
|
||||
|
||||
### `rig destroy`
|
||||
|
||||
Usage:
|
||||
- `rig destroy --state [--backup] --yes --confirm destroy-openrig-state`
|
||||
- `rig destroy --all [--backup] --yes --confirm destroy-openrig-state`
|
||||
|
||||
Notes:
|
||||
- This is the destructive recovery surface for polluted local OpenRig state.
|
||||
- `--state` stops the daemon, clears the active OpenRig listener on the configured port if needed, rotates or deletes the effective state root, and recreates an empty state root.
|
||||
- `--all` includes `--state` plus managed tmux session cleanup for sessions that are discoverable from the current OpenRig database.
|
||||
- `--backup` moves the state root aside to a collision-safe timestamped path such as `~/.openrig.backup-YYYYMMDD-HHMMSS`.
|
||||
- Managed tmux cleanup is intentionally conservative. It only removes sessions that are present in current DB state; unrelated tmux sessions are left alone.
|
||||
- Human output prints a compact destroy plan followed by the destroy result.
|
||||
|
||||
### `rig mcp`
|
||||
|
||||
@@ -159,7 +194,10 @@ Shipped MCP tools:
|
||||
Usage: `rig bootstrap <spec> [--plan] [--yes] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `spec`: path to a rig spec YAML file
|
||||
- `spec`: path to a rig spec YAML file or a library name
|
||||
|
||||
Notes:
|
||||
- Bare names resolve through the spec library before falling back to the raw source value.
|
||||
|
||||
### `rig requirements`
|
||||
|
||||
@@ -168,6 +206,10 @@ Usage: `rig requirements <spec> [--json]`
|
||||
Arguments:
|
||||
- `spec`: path to a rig spec YAML file
|
||||
|
||||
Notes:
|
||||
- `rig requirements` is the spec/app-specific dependency surface.
|
||||
- Use `rig doctor` for host-level install health, then `rig requirements <spec>` for rig-specific requirements.
|
||||
|
||||
### `rig up`
|
||||
|
||||
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
|
||||
@@ -187,6 +229,7 @@ Current behavior notes:
|
||||
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
|
||||
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
|
||||
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
|
||||
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
|
||||
|
||||
Success modes:
|
||||
- fresh boot
|
||||
@@ -206,18 +249,38 @@ Notes:
|
||||
- When `--snapshot` succeeds, human output includes the restore command.
|
||||
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
|
||||
|
||||
### `rig env`
|
||||
|
||||
Usage:
|
||||
- `rig env status <rig> [--json]`
|
||||
- `rig env logs <rig> [service] [--tail <n>]`
|
||||
- `rig env down <rig> [--volumes]`
|
||||
|
||||
Notes:
|
||||
- This surface is only meaningful for service-backed rigs and managed apps.
|
||||
- `status` resolves rig names or IDs and returns the env receipt with an honest freshness probe. The response includes `probeStatus` (fresh/stale/no_orchestrator) so operators can distinguish current truth from cached state.
|
||||
- `logs` proxies compose-backed service logs; `[service]` is optional.
|
||||
- `down` tears down the rig environment. `--volumes` overrides the stored down policy to force volume removal via `docker compose down --volumes`.
|
||||
- Note: `rig ps` does not yet surface env health. Runtime env truth is available through `rig env status` and the rig drawer `Env` tab.
|
||||
|
||||
### `rig ps`
|
||||
|
||||
Usage:
|
||||
- `rig ps [--json]`
|
||||
- `rig ps --nodes [--json]`
|
||||
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
|
||||
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
|
||||
|
||||
Notes:
|
||||
- `rig ps` lists rig summaries.
|
||||
- `rig ps --nodes` expands into a cross-rig node inventory including session name, runtime, startup status, restore outcome, attach command, resume command, and latest error.
|
||||
- Exit codes are defined in help:
|
||||
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
|
||||
- `rig ps --nodes` expands into a cross-rig node inventory. Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
|
||||
- JSON output for both rig and node tiers includes a `rigName` alias (equal to `name`) for forward compatibility; agent code should prefer `rigName`. Default `--json` is a bare array (back-compat); the envelope shape `{entries, totalRigs|totalNodes, truncated, hint?}` is only used when `--limit`, `--fields`, `--summary`, or `--filter` is set.
|
||||
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
|
||||
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
|
||||
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
|
||||
- `--filter <key=value>` accepts only `status`, `lifecycleState`, `name-prefix`, `name`. Unknown keys are rejected before any HTTP call with a clear error naming the supported list.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
|
||||
- Exit codes:
|
||||
- `0` success
|
||||
- `1` daemon not running
|
||||
- `1` daemon not running, or invalid `--filter` / `--limit`
|
||||
- `2` daemon fetch failure
|
||||
|
||||
### `rig snapshot`
|
||||
@@ -240,6 +303,29 @@ Notes:
|
||||
- Human output prints each restored node and any failed node error.
|
||||
- Non-zero exit if any restored node fails.
|
||||
|
||||
### `rig restore-check`
|
||||
|
||||
Usage: `rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`
|
||||
|
||||
Notes:
|
||||
- Checks restore readiness across running rigs (or one rig with `--rig`).
|
||||
- `--no-queue` skips queue file checks; `--no-hooks` skips hook checks.
|
||||
- Exit codes: `0` restorable (or restorable with caveats), `1` not restorable (red blockers found), `2` unknown / probe error.
|
||||
|
||||
### `rig restore-packet`
|
||||
|
||||
Usage: `rig restore-packet <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `write [options]` — generate a restore packet from a source session or JSONL file.
|
||||
- `read <packet-dir> [--json]` — render a restore packet's contents (human or JSON).
|
||||
- `validate <packet-dir> [--json]` — validate a restore packet against the v0 schema.
|
||||
|
||||
Notes:
|
||||
- Packet shape is the cross-runtime v0 standard (Claude Code and Codex transcripts both supported via runtime parsers + redaction).
|
||||
- `write` emits a packet directory with the canonical schema files plus `omitted-records` accounting.
|
||||
- `read` and `validate` operate on existing packet directories and do not mutate them.
|
||||
|
||||
### `rig export`
|
||||
|
||||
Usage: `rig export <rigId> [-o|--output <path>]`
|
||||
@@ -312,9 +398,11 @@ Subcommands:
|
||||
- `rename <name-or-id> <new-name> [--json]`
|
||||
|
||||
Notes:
|
||||
- `specs` is the library surface.
|
||||
- `specs` is the library surface for rigs, agents, and managed apps.
|
||||
- `preview` returns structured review data from the daemon.
|
||||
- `add` validates the file as either RigSpec or AgentSpec before copying it into the user library.
|
||||
- `add` accepts either a YAML spec file or a full spec directory containing `rig.yaml` or `agent.yaml`.
|
||||
- Directory adds copy the whole tree into the user library so adjacent agents, guidance, skills, and docs remain available.
|
||||
- `preview secrets-manager` is the canonical managed-app review example.
|
||||
|
||||
## Discovery and Topology Mutation
|
||||
|
||||
@@ -326,6 +414,18 @@ Notes:
|
||||
- Scans unmanaged tmux sessions.
|
||||
- `--draft` generates a candidate rig spec from the discovery set.
|
||||
|
||||
### `rig attach`
|
||||
|
||||
Usage:
|
||||
- `rig attach --self --rig <rigId> --node <logicalId> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
|
||||
- `rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
|
||||
|
||||
Notes:
|
||||
- `--self` is currently required.
|
||||
- Node attach and pod-create attach are exclusive modes.
|
||||
- In tmux-backed shells, the command records tmux attachment metadata; otherwise it records an `external_cli` attachment.
|
||||
- `--print-env` prints shell exports for `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`.
|
||||
|
||||
### `rig bind`
|
||||
|
||||
Usage: `rig bind <discoveredId> --rig <rigId> (--node <logicalId> | --pod <namespace> --member <name>)`
|
||||
@@ -356,6 +456,52 @@ Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
|
||||
Notes:
|
||||
- Adds a pod fragment to a running rig.
|
||||
- `--rig-root` controls agent resolution.
|
||||
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
|
||||
|
||||
### Session source declaration (`session_source`)
|
||||
|
||||
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
|
||||
|
||||
```yaml
|
||||
# Fork from a prior native runtime conversation. Captures and persists a NEW
|
||||
# post-fork token; the parent token is NEVER persisted onto the new seat.
|
||||
members:
|
||||
- id: reviewer-2
|
||||
runtime: claude-code # or "codex"; not valid on terminal
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id # v1 fork mode supports "native_id" only
|
||||
value: "0b0165d7-cb4d-4650-90de-15c0a1ede9e6"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Rebuild from operator-declared artifacts (CULTURE, role doc, handover packet,
|
||||
# queue files, session logs). Fresh-launches the harness and seeds the running
|
||||
# TUI with the artifacts in the operator-declared trust-precedence order.
|
||||
# The seat's continuityOutcome is `rebuilt` (NEVER `fresh`/`resumed`/`forked`)
|
||||
# and NO `resumeToken` is persisted.
|
||||
members:
|
||||
- id: writer-2
|
||||
runtime: claude-code # or "codex"; not valid on terminal
|
||||
session_source:
|
||||
mode: rebuild
|
||||
ref:
|
||||
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
|
||||
value: # ordered list, highest-trust first
|
||||
- /Users/wrandom/code/substrate/shared-docs/rigs/<rig>/CULTURE.md
|
||||
- /Users/wrandom/code/substrate/shared-docs/specs/agents/<role>.md
|
||||
- /path/to/handover-packet.md
|
||||
- /path/to/state/<pod>/<member>.queue.md
|
||||
- /path/to/state/<pod>/shared.session.log
|
||||
- /path/to/state/<pod>/<member>.session.log
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `terminal` runtime rejects `session_source` (no native fork primitive; no agent context to rebuild).
|
||||
- `mode: fork` requires `ref.kind: native_id` and a non-empty `ref.value` string. Other ref kinds (`artifact_path`, `name`, `last`) are reserved shapes for follow-up slices and are refused in v1 fork mode.
|
||||
- `mode: rebuild` requires `ref.kind: artifact_set` and a non-empty `ref.value` array of paths. Missing paths are recorded as gaps and the launch proceeds with what resolved; if NO declared paths resolve, the launch fails with a clear error.
|
||||
- The two modes are mutually exclusive on a given member; mixing is a schema error.
|
||||
|
||||
### `rig unclaim`
|
||||
|
||||
@@ -364,6 +510,15 @@ Usage: `rig unclaim <sessionRef> [--json]`
|
||||
Notes:
|
||||
- Releases an adopted session without killing its tmux session.
|
||||
|
||||
### `rig release`
|
||||
|
||||
Usage: `rig release <rigId> [--delete] [--json]`
|
||||
|
||||
Notes:
|
||||
- Releases all claimed/adopted sessions from a rig without killing their tmux sessions.
|
||||
- `--delete` removes the rig record after a clean release.
|
||||
- OpenRig-launched nodes still require `rig down`.
|
||||
|
||||
### `rig launch`
|
||||
|
||||
Usage: `rig launch <rigId> <nodeRef> [--json]`
|
||||
@@ -391,7 +546,7 @@ Notes:
|
||||
|
||||
### `rig whoami`
|
||||
|
||||
Usage: `rig whoami [--node-id <id>] [--session <name>] [--json]`
|
||||
Usage: `rig whoami [--node-id <id>] [--session <name>] [--host <id>] [--json]`
|
||||
|
||||
Identity resolution order:
|
||||
1. `--node-id`
|
||||
@@ -406,6 +561,7 @@ Notes:
|
||||
- If the daemon is unreachable but an identity source can still be resolved, `--json` returns a partial result instead of crashing.
|
||||
- Human-readable output includes transcript location and context usage when available.
|
||||
- In Claude Code projects, unattended `rig whoami` on boot may require the local permissions allow list to include `Bash(rig:*)`.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Identity resolution happens on the REMOTE rig (each host has its own daemon + tmux + identity context); local `--node-id`/`--session` flags are forwarded to the remote `rig whoami` invocation. The remote rig's output is verbatim passthrough on success; failure is distinguished into the same `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` enum as `rig ps --host` and `rig send --host`.
|
||||
|
||||
### `rig transcript`
|
||||
|
||||
@@ -420,23 +576,68 @@ Notes:
|
||||
|
||||
### `rig send`
|
||||
|
||||
Usage: `rig send <session> <text> [--verify] [--force] [--json]`
|
||||
Usage: `rig send <session> <text> [--verify] [--force] [--wait-for-idle <s>] [--host <id>] [--json]`
|
||||
|
||||
Notes:
|
||||
- Uses the two-step send pattern automatically: paste text, wait, submit Enter.
|
||||
- `--verify` requests delivery verification.
|
||||
- `--force` overrides mid-task safety checks.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below. SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim.
|
||||
|
||||
### `rig capture`
|
||||
|
||||
Usage:
|
||||
- `rig capture <session> [--lines <n>] [--json]`
|
||||
- `rig capture --rig <name> [--lines <n>] [--json]`
|
||||
- `rig capture --pod <name> --rig <name> [--lines <n>] [--json]`
|
||||
- `rig capture <session> [--lines <n>] [--host <id>] [--json]`
|
||||
- `rig capture --rig <name> [--lines <n>] [--host <id>] [--json]`
|
||||
- `rig capture --pod <name> --rig <name> [--lines <n>] [--host <id>] [--json]`
|
||||
|
||||
Default:
|
||||
- `--lines 20`
|
||||
|
||||
Notes:
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below.
|
||||
|
||||
### Cross-host execution (`--host <id>`)
|
||||
|
||||
`rig send` and `rig capture` accept a `--host <id>` flag that routes the
|
||||
command to a remote host over single-hop SSH. v0 is CLI-side shell-out
|
||||
only — the local daemon is not involved in routing, and there is no
|
||||
daemon-to-daemon networking. The remote host is expected to have its own
|
||||
managed `rig` available on `$PATH`.
|
||||
|
||||
Hosts are declared by the operator in `~/.openrig/hosts.yaml`:
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
- id: vm-claude-test
|
||||
transport: ssh
|
||||
target: vm-claude-test.local
|
||||
user: wrandom # optional
|
||||
notes: "Tart VM" # optional
|
||||
- id: laptop-b
|
||||
transport: ssh
|
||||
target: laptop-b.tail-scale-net
|
||||
user: wrandom
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `hosts` is required and must be a non-null array.
|
||||
- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP).
|
||||
- `user` and `notes` are optional.
|
||||
- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly).
|
||||
- A missing or invalid file returns a clear error pointing at the canonical path.
|
||||
|
||||
The CLI distinguishes four structured failure modes (operators get an
|
||||
actionable error per mode; JSON output preserves the `failedStep` enum):
|
||||
|
||||
- `ssh-unreachable` — SSH itself failed (connection refused, host key mismatch, etc.). Verify SSH access and the registry entry.
|
||||
- `permission-gate` — SSH hit an auth/permission gate (Permission denied, Keychain). The error includes a hint to the keychain-over-SSH field note.
|
||||
- `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh <target> rig daemon start`.
|
||||
- `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced.
|
||||
|
||||
Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
|
||||
|
||||
### `rig broadcast`
|
||||
|
||||
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--json]`
|
||||
@@ -481,12 +682,177 @@ Notes:
|
||||
- `clear` is destructive and rig-scoped. Removes all messages for that rig.
|
||||
- `watch --tmux` starts a dedicated tmux watcher session.
|
||||
|
||||
## Coordination Primitive (PL-004 Phase A)
|
||||
|
||||
Two top-level commands back the SQLite-canonical coordination layer. They speak only to the daemon HTTP API; they do NOT touch the POC `rigx-stream-proto` / `rigx-queue-proto` filesystem state. POC and daemon coexist at OPERATOR level only.
|
||||
|
||||
### `rig stream`
|
||||
|
||||
Usage: `rig stream <subcommand>` — L1 append-only intake stream.
|
||||
|
||||
Subcommands:
|
||||
- `emit --source <session> --body <text> [--hint-destination <session>] [--hint-type <type>] [--hint-urgency <urgency>] [--hint-tags <csv>] [--interrupt] [--id <streamItemId>] [--json]`
|
||||
- `list [--source <session>] [--hint-destination <session>] [--limit <n>] [--after <sortKey>] [--include-archived] [--json]`
|
||||
- `show <streamItemId> [--json]`
|
||||
- `archive <streamItemId> [--json]`
|
||||
|
||||
Notes:
|
||||
- `--id` is for idempotency; same id returns the same row, body of subsequent calls is ignored.
|
||||
- `archive` is soft — the row remains for audit and is excluded from `list` unless `--include-archived` is passed.
|
||||
|
||||
### `rig queue`
|
||||
|
||||
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
|
||||
|
||||
Subcommands:
|
||||
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
|
||||
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
|
||||
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
|
||||
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
|
||||
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--json]` — transactional close-as-handed-off + create-new
|
||||
- `fallback <qitemId> --destination <session> [--reason <text>] [--json]` — reroute to fallback seat
|
||||
- `show <qitemId> [--json]`
|
||||
- `transitions <qitemId> [--json]` — append-only transition log
|
||||
- `list [--destination <session>] [--source <session>] [--state <csv>] [--limit <n>] [--json]`
|
||||
- `overdue [--json]` — in-progress qitems past closure_required_at
|
||||
- `inbox-drop <destinationSession> --sender <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <inboxId>] [--json]`
|
||||
- `inbox-absorb <inboxId> --receiver <session> [--json]` — promote a pending inbox entry to a queue_item
|
||||
- `inbox-deny <inboxId> --receiver <session> --reason <text> [--json]`
|
||||
- `inbox-pending <destinationSession> [--json]`
|
||||
- `outbox-record --sender <session> --destination <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <outboxId>] [--json]`
|
||||
- `outbox-list <senderSession> [--limit <n>] [--json]`
|
||||
|
||||
Hot-potato strict-rejection (load-bearing API contract):
|
||||
- `update --state done` REQUIRES `--closure-reason` from one of: `handed_off_to`, `blocked_on`, `denied`, `canceled`, `no-follow-on`, `escalation`. Missing or invalid reason → exit 1 with structured error naming the 6 valid values.
|
||||
- `closure-reason` of `handed_off_to`, `blocked_on`, or `escalation` additionally requires `--closure-target`.
|
||||
- All hot-potato enforcement happens at the daemon domain layer; every surface (CLI, future MCP, future UI) inherits the same guarantee.
|
||||
|
||||
Closure-reason semantics:
|
||||
- `handed_off_to` — work continues at a different seat (target = new owner). `handoff` subcommand is the preferred path; `update` accepts it for non-handoff terminal closures.
|
||||
- `blocked_on` — parked pending another qitem (target = blocker qitem_id).
|
||||
- `denied` — receiver rejected the work.
|
||||
- `canceled` — sender or receiver withdrew.
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (target = escalation target).
|
||||
|
||||
## Coordination Project / Classifier and View (PL-004 Phase B)
|
||||
|
||||
Two top-level commands extend the coordination layer with L2 (project / classifier) and L5 (views).
|
||||
|
||||
### `rig project`
|
||||
|
||||
Usage: `rig project <subcommand>` — L2 agent-backed classifier with daemon-enforced lease + idempotency + reclaim.
|
||||
|
||||
Subcommands:
|
||||
- `lease-acquire [options]` — acquire the active classifier lease for the caller.
|
||||
- `lease-heartbeat [options]` — send a heartbeat for an active classifier lease (extends TTL).
|
||||
- `lease-show [options]` — show the currently-active classifier lease.
|
||||
- `reclaim-classifier [options]` — operator verb to reclaim the active classifier lease. Use `--if-dead` to refuse if holder is still alive.
|
||||
- `classify <streamItemId> [options]` — project a stream item with classification fields. Idempotent on `stream_item_id`; requires an active lease.
|
||||
- `list [options]` — list project classifications with filters.
|
||||
- `show <projectId> [options]` — show one project classification.
|
||||
|
||||
Notes:
|
||||
- Lease semantics: only one classifier holds the active lease at a time. Heartbeats extend TTL; lease expiry frees the slot for the next acquirer.
|
||||
- `classify` enforces L1→L2 foreign-key existence: the referenced `stream_items` row must exist or the call is rejected before any state mutation.
|
||||
- `reclaim-classifier` is the operator-side verb to recover from a hung classifier; `--if-dead` adds a liveness guard so a still-heartbeating classifier is not stolen from.
|
||||
|
||||
### `rig view`
|
||||
|
||||
Usage: `rig view <subcommand>` — L5 daemon-backed views over coordination state.
|
||||
|
||||
Subcommands:
|
||||
- `list [options]` — list built-in + custom views.
|
||||
- `show <viewName> [options]` — run a view (built-in or custom).
|
||||
- `register [options]` — register or update a custom view.
|
||||
|
||||
Built-in views:
|
||||
- `recently-active`, `founder`, `pod-load`, `escalations`, `held`, `activity`.
|
||||
|
||||
Notes:
|
||||
- Views emit a `view.changed` SSE event on every state mutation that affects them. Queue update mutations bridge to `queue.updated` and then to `view.changed` for all six built-in views (Phase B R2).
|
||||
- Custom view registration writes to the `views_custom` table; the daemon hot-reloads on registration.
|
||||
|
||||
## Coordination Watchdog (PL-004 Phase C)
|
||||
|
||||
`rig watchdog` registers, lists, inspects, and stops daemon-native scheduler jobs. The scheduler runs inside the daemon supervision tree and persists state in SQLite (`watchdog_jobs`/`watchdog_history`); jobs survive daemon restarts. Three policies are available at v1: `periodic-reminder`, `artifact-pool-ready`, and `edge-artifact-required`. The fourth POC policy `workflow-keepalive` is rejected with `policy_deferred_to_phase_d` and ships in Phase D.
|
||||
|
||||
### `rig watchdog`
|
||||
|
||||
- `register --spec <path> --policy <name> --target-session <s> --interval-seconds <n> --registered-by <s>` — register a job from a YAML spec; `--active-wake-interval-seconds` and `--scan-interval-seconds` are pool-ready-specific opt-ins.
|
||||
- `list` — list all jobs (active + stopped + terminal).
|
||||
- `show <job_id>` — show one job.
|
||||
- `status <job_id>` — show job + recent evaluation history (last 20 entries).
|
||||
- `stop <job_id> [--reason <text>]` — operator stop; scheduler skips the job thereafter.
|
||||
|
||||
History records only loud evaluations: `sent` (delivery executed) or `terminal` (policy declared the job done). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded — POC parity so agents are not woken about scheduler polls. Loud `skipped` rows are recorded only if a policy returns a non-quiet reason.
|
||||
|
||||
Phase D extends the policy enum with `workflow-keepalive` (the policy deferred from Phase C). It reads `workflow_instances` directly via SQLite, requires `status: active|waiting`, and resolves frontier qitem owners from `queue_items`.
|
||||
|
||||
## Workflow Runtime (PL-004 Phase D)
|
||||
|
||||
`rig workflow` operates on the daemon-native Workflow Runtime: declarative spec validation, instance creation, step projection (transactional-scribe), trace, and idempotent continue. Workflow specs are markdown/YAML files on disk (workspace-surface); the daemon caches them in SQLite for fast lookup.
|
||||
|
||||
### `rig workflow`
|
||||
|
||||
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness).
|
||||
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner.
|
||||
- `project --instance <id> --current-packet <qitem-id> --exit <handoff|waiting|done|failed> --actor-session <session>` — close the current packet AND project the next-step packet IN THE SAME daemon transaction (transactional-scribe; lost handoffs impossible by design). `--result-note <text>`, `--blocked-on <ref>`, `--next-owner <session>` modify behavior.
|
||||
- `list [--status <s>]` — list instances; optionally filter by status (`active`/`waiting`/`completed`/`failed`).
|
||||
- `show <instanceId>` — show one instance.
|
||||
- `trace <instanceId>` — show the instance + its append-only step trail (audit-only).
|
||||
- `continue <instanceId>` — idempotent inspector; in v1 returns the current state.
|
||||
|
||||
The owner-as-author + workflow-as-transactional-scribe contract is enforced by the daemon. The owner of a packet decides when it closes; the workflow runtime atomically records the closure AND creates/projects the next qitem per the workflow spec, in a single daemon transaction.
|
||||
|
||||
## Operational Inspection
|
||||
|
||||
Read-only inspection commands for context-window state, compaction planning, workflow heartbeat, and seat handover observability. Default mode is read-only across this section.
|
||||
|
||||
### `rig context`
|
||||
|
||||
Usage: `rig context [--rig <name>] [--threshold <pct>] [--refresh] [--json]`
|
||||
|
||||
Notes:
|
||||
- Shows context-window usage across running agents.
|
||||
- `--rig <name>` narrows to a single rig; `--threshold <pct>` filters to seats at or above the percent (plus unknown + stale).
|
||||
- `--refresh` re-samples context usage before displaying instead of reading the latest cached snapshot.
|
||||
|
||||
### `rig compact-plan`
|
||||
|
||||
Usage: `rig compact-plan [--rig <name>] [--refresh] [--threshold-tokens <n>] [--threshold-percent <0-100>] [--json]`
|
||||
|
||||
Notes:
|
||||
- Plans Claude compact-in-place candidates without compacting anything (read-only triage).
|
||||
- `--threshold-tokens <n>` is the estimated used-token threshold; `--threshold-percent <0-100>` is the used-percent threshold when the context window size is missing.
|
||||
- Output identifies seats that are candidates for compaction by current heuristics; the operator decides what (if anything) to compact.
|
||||
|
||||
### `rig heartbeat`
|
||||
|
||||
Usage: `rig heartbeat [--rig <name>] [--nudge] [--include-done] [--json]`
|
||||
|
||||
Notes:
|
||||
- Shows workflow execution proof state from queue files.
|
||||
- Default mode is read-only. `--nudge` sends informational proof instructions to stalled or unproven owners; it does not modify queue files or reroute work.
|
||||
- `--include-done` includes done/handed-off queue items in the output (excluded by default).
|
||||
|
||||
### `rig seat`
|
||||
|
||||
Usage: `rig seat <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `status <seat> [options]` — show read-only seat handover observability status.
|
||||
- `handover <seat> [options]` — plan a safe two-phase seat handover.
|
||||
|
||||
Notes:
|
||||
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
|
||||
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
- `rig claim`
|
||||
- `rig blame`
|
||||
- `rig replay`
|
||||
- `rig env`
|
||||
|
||||
If older docs or habits mention them, treat those references as stale.
|
||||
+91
-24
@@ -3,10 +3,10 @@
|
||||
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
|
||||
|
||||
Current footprint:
|
||||
- daemon: 133 source files (79 domain, 21 routes, 10 adapters, 17 migrations, 6 other)
|
||||
- CLI: 37 source files
|
||||
- UI: 83 source files
|
||||
- total: 253 source files
|
||||
- daemon: 150 source files (90 domain, 22 routes, 11 adapters, 20 migrations, 7 other)
|
||||
- CLI: 49 source files
|
||||
- UI: 87 source files
|
||||
- total: 286 source files
|
||||
|
||||
How to use this doc:
|
||||
- `Exports` lists the public symbols defined by the file.
|
||||
@@ -16,6 +16,7 @@ How to use this doc:
|
||||
Fast lookup:
|
||||
- Daemon app wiring and mounted HTTP surface: `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/routes/*`
|
||||
- AgentSpec / pod-aware engine: `packages/daemon/src/domain/agent-*`, `profile-resolver.ts`, `projection-planner.ts`, `startup-*`, `rigspec-*`, `pod-repository.ts`
|
||||
- Rig environment / managed-app substrate: `packages/daemon/src/adapters/compose-services-adapter.ts`, `packages/daemon/src/domain/service-orchestrator.ts`, `packages/daemon/src/domain/services-readiness.ts`, `packages/daemon/src/routes/env.ts`
|
||||
- Snapshot / restore / continuity: `packages/daemon/src/domain/snapshot-*`, `checkpoint-store.ts`, `restore-orchestrator.ts`
|
||||
- Transport / communication: `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/routes/transport.ts`
|
||||
- Transcripts / persistence: `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/domain/history-query.ts`, `packages/daemon/src/routes/transcripts.ts`
|
||||
@@ -23,13 +24,15 @@ Fast lookup:
|
||||
- Chat: `packages/daemon/src/domain/chat-repository.ts`, `packages/daemon/src/routes/chat.ts`
|
||||
- Spec review / library / identity: `packages/daemon/src/domain/spec-review-service.ts`, `packages/daemon/src/domain/spec-library-service.ts`, `packages/daemon/src/domain/whoami-service.ts`, `packages/daemon/src/routes/spec-review.ts`, `packages/daemon/src/routes/spec-library.ts`, `packages/daemon/src/routes/whoami.ts`
|
||||
- Node inventory: `packages/daemon/src/domain/node-inventory.ts`
|
||||
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
|
||||
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
|
||||
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (31 groups)
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (40 groups)
|
||||
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
|
||||
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
|
||||
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
|
||||
- UI spec/library surfaces: `packages/ui/src/components/RigSpecDisplay.tsx`, `packages/ui/src/components/AgentSpecDisplay.tsx`, `packages/ui/src/components/LibraryReview.tsx`, `packages/ui/src/components/SpecsWorkspace.tsx`
|
||||
- UI managed-app runtime surfaces: `packages/ui/src/components/RigEnvPanel.tsx`, `packages/ui/src/hooks/useRigEnv.ts`, `packages/ui/src/lib/build-setup-prompt.ts`
|
||||
- UI live identity surfaces: `packages/ui/src/components/LiveIdentityDisplay.tsx`, `packages/ui/src/components/LiveNodeDetails.tsx`
|
||||
- UI data layer: `packages/ui/src/hooks/*`
|
||||
|
||||
@@ -38,14 +41,15 @@ Fast lookup:
|
||||
### Entrypoints And Wiring
|
||||
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
|
||||
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 17 migrations, wires legacy, reboot, North Star, Post-North-Star, and Final Stretch 1 services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 20 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, and Final Stretch 3 managed-app services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
|
||||
### Adapters
|
||||
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/claude-resume.ts`: Resume adapter for claude sessions. Exports: ClaudeResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux-transport.ts`: Adapter/util module for cmux transport integration. Exports: createCmuxCliTransport. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux.ts`: Adapter/util module for cmux integration. Exports: CmuxAdapter, CmuxResult, CmuxStatus, CmuxSurface, CmuxTransport, CmuxTransportFactory, CmuxWorkspace. Related: none.
|
||||
- `packages/daemon/src/adapters/compose-services-adapter.ts`: Compose-backed rig-environment adapter for `up`, `ps`, `logs`, and teardown. Exports: ComposePsService, ComposeServicesAdapter, ComposeServicesUpParams, ComposeServicesUpResult, ComposeServicesLogsParams, ComposeServicesLogsResult, ComposeServicesDownParams, ComposeServicesDownResult. Related: `packages/daemon/src/adapters/tmux-exec.js`.
|
||||
- `packages/daemon/src/adapters/codex-resume.ts`: Resume adapter for codex sessions. Exports: CodexResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`: Adapter implementation for codex runtime integration. Exports: CodexAdapterFsOps, CodexRuntimeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/terminal-adapter.ts`: Infrastructure runtime adapter for terminal/shell nodes. No-op project/deliver/launch, immediate readiness. Exports: TerminalAdapter. Related: `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
@@ -75,19 +79,65 @@ Fast lookup:
|
||||
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/021_seat_handover_observability.ts`: Database migration for seat-handover observability fields. Exports: seatHandoverObservabilitySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/022_node_codex_config_profile.ts`: Database migration adding Codex config-profile field on nodes. Exports: nodeCodexConfigProfileSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/023_stream_items.ts`: PL-004 Phase A — L1 stream items table (append-only intake/audit root). Exports: streamItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/024_queue_items.ts`: PL-004 Phase A — L3 queue items table (owned-work queue with closure obligation). Exports: queueItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/025_queue_transitions.ts`: PL-004 Phase A — append-only queue state transition log. Exports: queueTransitionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/026_inbox_entries.ts`: PL-004 Phase A — mailbox-style inbox for asynchronous deposit. Exports: inboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/027_outbox_entries.ts`: PL-004 Phase A — sender-side outbox audit. Exports: outboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/028_project_classifications.ts`: PL-004 Phase B — L2 project classifications table (idempotent on stream_item_id; FK to stream_items). Exports: projectClassificationsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/029_classifier_leases.ts`: PL-004 Phase B — single-writer classifier lease table (partial UNIQUE INDEX on state='active'). Exports: classifierLeasesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/030_views_custom.ts`: PL-004 Phase B — custom view registry. Exports: viewsCustomSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/031_watchdog_jobs.ts`: PL-004 Phase C — watchdog jobs (canonical scheduler state; policy enum + intervals + lifecycle). Exports: watchdogJobsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/032_watchdog_history.ts`: PL-004 Phase C — append-only watchdog evaluation history (sent/skipped/terminal; not_due polls not recorded). Exports: watchdogHistorySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/033_workflow_specs.ts`: PL-004 Phase D — workflow specs (cached read-through from markdown/YAML; UNIQUE on name+version; source_hash invalidation). Exports: workflowSpecsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
- `packages/daemon/src/domain/watchdog-history-log.ts`: Append-only writer for watchdog_history. Records meaningful evaluations only (sent/skipped/terminal). API surface intentionally lacks update/delete. Exports: WatchdogHistoryLog, WatchdogHistoryEntry, WatchdogOutcome. Related: `packages/daemon/src/db/migrations/032_watchdog_history.js`.
|
||||
- `packages/daemon/src/domain/watchdog-policy-engine.ts`: Resolves policy by name, parses spec_yaml context, dispatches policy.evaluate(), records meaningful outcomes via history-log, emits watchdog.* events, calls injected delivery callback for action=send. Exports: WatchdogPolicyEngine, DeliveryFn, DeliveryRequest, DeliveryOutcome. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/watchdog-scheduler.ts`: Tick-driven daemon-native scheduler that joins the supervision tree. Filters listActive() through isDue(), evaluates due jobs sequentially, supports start/stop with graceful in-flight await. Exports: WatchdogScheduler, isDue. Related: `packages/daemon/src/domain/watchdog-policy-engine.js`, `packages/daemon/src/domain/watchdog-jobs-repository.js`.
|
||||
- `packages/daemon/src/domain/policies/types.ts`: Policy contract (Policy, PolicyJob, PolicyEvaluation). Exports: Policy, PolicyJob, PolicyEvaluation. Related: none.
|
||||
- `packages/daemon/src/domain/policies/periodic-reminder.ts`: Policy that returns send {target, message} unconditionally; throws policy_spec_invalid if context.target.session or context.message missing. Translated from POC `lib/policies/periodic-reminder.mjs`. Exports: periodicReminderPolicy. Related: `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-ready.ts`: Policy that scans context.pools and skips with no_actionable_artifacts when empty; otherwise sends a formatted message capped at max_items. Translated from POC `lib/policies/artifact-pool-ready.mjs`. Exports: artifactPoolReadyPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/edge-artifact-required.ts`: Policy that scans source_pools and target_pools, computes source-key per artifact, and sends an edge-repair message for sources lacking matching downstream artifacts. Skips with no_missing_edge_artifacts when all sources matched. Translated from POC `lib/policies/edge-artifact-required.mjs`. Exports: edgeArtifactRequiredPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-helpers.ts`: Shared filesystem scanner + formatter for artifact-pool-ready and edge-artifact-required. Translated from POC `lib/policies/artifact-pool.js`. Exports: scanArtifactPools, formatArtifactList, sourceKeyFor, ArtifactPoolSpec, ScannedArtifact. Related: none.
|
||||
- `packages/daemon/src/routes/watchdog.ts`: Hono route module for `/api/watchdog` (`POST /register`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:job_id`, `GET /:job_id/status`, `POST /:job_id/stop`). SSE/literal paths mounted before /:job_id catchall per Phase A R1 route-order discipline. Exports: watchdogRoutes. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/policies/workflow-keepalive.ts`: PL-004 Phase D — workflow-keepalive policy (carry-forward from Phase C deferral). Reads workflow_instances directly via SQLite (audit row 18 LOAD-BEARING); requires status active|waiting; resolves frontier qitem owners via queue_items. Translated from POC `lib/policies/workflow-keepalive.mjs`; source-of-truth switched from markdown to SQLite. Exports: makeWorkflowKeepalivePolicy. Related: `packages/daemon/src/domain/policies/types.js`, `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
|
||||
### Domain: Workflow Runtime (PL-004 Phase D)
|
||||
- `packages/daemon/src/domain/workflow-types.ts`: Shared types (WorkflowSpec, WorkflowInstance, WorkflowStepTrailEntry, WorkflowExitKind). Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb` + fixture YAMLs. Exports: WorkflowSpec, WorkflowStepSpec, WorkflowRoleSpec, WorkflowInvariants, WorkflowClosureMessages, WorkflowLoopGuards, WorkflowInstance, WorkflowInstanceStatus, WorkflowStepTrailEntry, WorkflowExitKind, WorkflowSpecRow. Related: none.
|
||||
- `packages/daemon/src/domain/workflow-spec-cache.ts`: Read-through cache from markdown/YAML spec files into workflow_specs table. Workspace-surface reconciliation: source_hash invalidates the cache on next read; valid operator edits to spec files win. Uses the `yaml` package for parsing. Exports: WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec. Related: `packages/daemon/src/db/migrations/033_workflow_specs.js`.
|
||||
- `packages/daemon/src/domain/workflow-validator.ts`: Validates a workflow spec against role resolution, step uniqueness, allowed-exits consistency, and (optional via injected callback) seat liveness. Returns structured ValidationResult with severity-tagged issues. Exports: WorkflowValidator, ValidationIssue, ValidationResult, SeatLivenessCheckFn. Related: `packages/daemon/src/domain/workflow-types.js`.
|
||||
- `packages/daemon/src/domain/workflow-instance-store.ts`: CRUD on workflow_instances + frontier tracking. Survives daemon restart (queries SQLite directly). Exports: WorkflowInstanceStore, WorkflowInstanceError, CreateWorkflowInstanceInput. Related: `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
- `packages/daemon/src/domain/workflow-step-trail-log.ts`: Append-only writer/reader for workflow_step_trails. API surface lacks update/delete (contract enforcement). Mirrors Phase A's queue-transition-log + Phase C's watchdog-history-log shape. Exports: WorkflowStepTrailLog, WorkflowStepTrailRecordInput. Related: `packages/daemon/src/db/migrations/035_workflow_step_trails.js`.
|
||||
- `packages/daemon/src/domain/workflow-projector.ts`: LOAD-BEARING. Implements the transactional-scribe contract: closing a workflow_instances frontier qitem AND creating the next-step queue_items row AND appending the workflow_step_trails entry AND updating instance frontier+status all happen in the SAME daemon transaction (db.transaction). If any side throws, all roll back. Lost handoffs are impossible by design. Post-commit, fans out persisted events + nudges next owner. Uses Phase A QueueRepository.createWithinTransaction() (Phase D extension point). Exports: WorkflowProjector, WorkflowProjectorError, ProjectStepInput, ProjectStepResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/workflow-instance-store.js`, `packages/daemon/src/domain/workflow-step-trail-log.js`.
|
||||
- `packages/daemon/src/domain/workflow-runtime.ts`: Facade over spec-cache + validator + instance-store + projector + step-trail-log. Operations: validate, instantiate (creates instance + entry qitem in same transaction), project (delegates to projector), continue (idempotent inspector). Exports: WorkflowRuntime, WorkflowRuntimeDeps, InstantiateInput, InstantiateResult, WorkflowInstanceError, WorkflowProjectorError, WorkflowSpecError. Related: `packages/daemon/src/domain/workflow-projector.js`, `packages/daemon/src/domain/workflow-spec-cache.js`.
|
||||
- `packages/daemon/src/routes/workflow.ts`: Hono route module for `/api/workflow` (`POST /validate`, `POST /instantiate`, `POST /project`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:instance_id`, `GET /:instance_id/trace`, `POST /:instance_id/continue`). SSE/literal paths mounted before /:instance_id catchall per Phase A R1 route-order discipline. Exports: workflowRoutes. Related: `packages/daemon/src/domain/workflow-runtime.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
|
||||
### Domain: Core Runtime
|
||||
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
|
||||
- `packages/daemon/src/domain/event-bus.ts`: Persistent event log plus in-process pub/sub for routes, SSE, and domain emitters. Exports: EventBus. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/graph-projection.ts`: Pure projection from persisted rig/session state to React Flow graph nodes and edges. Exports: ReactFlowGraph, RigGraphInput, projectRigToGraph. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/context-monitor.ts`: Daemon-owned context-usage recorder that samples live runtime state and updates persistent usage summaries. Exports: ContextMonitor. Related: `packages/daemon/src/domain/context-usage-store.js`.
|
||||
- `packages/daemon/src/domain/context-usage-store.ts`: SQLite-backed store for per-node context usage snapshots used by whoami/node inventory surfaces. Exports: ContextUsageStore, ContextUsageSnapshot, ContextUsageStoreOpts. Related: none.
|
||||
- `packages/daemon/src/domain/node-launcher.ts`: Atomic node launch service that creates tmux, session rows, bindings, and launch events together. Exports: LaunchResult, NodeLauncher. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-name.js`, +2 more.
|
||||
- `packages/daemon/src/domain/ps-projection.ts`: Builds the `/api/ps` summary view over rigs, nodes, sessions, and latest snapshots. Exports: PsEntry, PsProjectionService. Related: none.
|
||||
- `packages/daemon/src/domain/reconciler.ts`: Marks missing tmux sessions as detached and keeps persisted session state aligned with reality. Exports: ReconcileResult, Reconciler. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, and edge state. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, edge, and rig-services state. Also projects `hasServices` into rig summaries for runtime/UI gating. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rig-teardown.ts`: Graceful rig shutdown/delete orchestrator used by `/api/down`. Exports: RigTeardownOrchestrator, TeardownResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +2 more.
|
||||
- `packages/daemon/src/domain/service-orchestrator.ts`: Rig-scoped services orchestrator for env boot, readiness gating, receipt capture, and teardown. Exports: ServiceBootResult, ServiceOrchestrator, ServiceTeardownResult. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/services-readiness.js`.
|
||||
- `packages/daemon/src/domain/session-name.ts`: Tmux session-name derivation and validation helpers. Exports: deriveSessionName, validateSessionName. Related: none.
|
||||
- `packages/daemon/src/domain/session-registry.ts`: Session and binding lifecycle repository, including claimed-session registration and startup-status updates. Exports: SessionRegistry. Related: `packages/daemon/src/domain/session-name.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
|
||||
- `packages/daemon/src/domain/services-readiness.ts`: Shared readiness evaluator for RigEnv wait targets (`url`, `tcp`, `service + condition`). Exports: WaitTargetFailure, WaitTargetSuccess, evaluateWaitTargets. Related: none.
|
||||
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, RigEnv/service contracts, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, EnvCheckpoint, EnvReceipt, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigServicesRecord, RigServicesSpec, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
|
||||
- `packages/daemon/src/domain/up-command-router.ts`: Source router that classifies a path as a rig spec or rig bundle before bootstrap. Exports: RouteResult, SourceKind, UpCommandRouter. Related: `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
|
||||
### Domain: Specs, Resolution, Startup
|
||||
@@ -104,8 +154,8 @@ Fast lookup:
|
||||
- `packages/daemon/src/domain/rigspec-schema.ts`: Validation and normalization for both legacy and rebooted RigSpec schemas. Exports: LegacyRigSpecSchema, RigSpecSchema. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/runtime-adapter.ts`: Runtime-adapter contract plus bridge types used by startup orchestration and runtime-specific adapters. Exports: InstalledResource, NodeBinding, ProjectionResult, ReadinessResult, ResolvedStartupFile, RuntimeAdapter, StartupDeliveryResult. Related: `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/spec-validation-service.ts`: Pure validation helpers for raw AgentSpec and RigSpec YAML strings. Exports: validateAgentSpecFromYaml, validateRigSpecFromYaml. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, and provenance/source-state for preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, provenance/source-state, and managed-app services metadata used by preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, RigSpecServicesReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, marks service-backed rigs with `hasServices`, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/domain/whoami-service.ts`: Daemon-side runtime identity projector used by `/api/whoami`. Resolves by node or session, expands peers/edges/transcript helpers, and surfaces ambiguity honestly. Exports: WhoamiResult, WhoamiAmbiguousError, WhoamiService. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/startup-orchestrator.ts`: Startup execution engine for projection, startup-file delivery, action execution, readiness, and persisted replay context. Exports: StartupInput, StartupOrchestrator, StartupResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`, +2 more.
|
||||
- `packages/daemon/src/domain/startup-resolver.ts`: Pure additive startup-layer merge function across agent, profile, rig, pod, member, and operator layers. Exports: StartupLayerInputs, resolveStartup. Related: `packages/daemon/src/domain/types.js`.
|
||||
@@ -113,8 +163,8 @@ Fast lookup:
|
||||
|
||||
### Domain: Snapshots And Restore
|
||||
- `packages/daemon/src/domain/checkpoint-store.ts`: Checkpoint persistence, lookup, and rig-wide checkpoint aggregation. Exports: CheckpointStore. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, and topology ordering. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
|
||||
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, and startup context. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
|
||||
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, topology ordering, and RigEnv boot gating before agent restore. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
|
||||
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, startup context, and the latest rig-env receipt. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
|
||||
- `packages/daemon/src/domain/snapshot-repository.ts`: SQLite-backed repository for snapshot CRUD and listing. Exports: SnapshotRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
|
||||
### Domain: Packages And Install Engine
|
||||
@@ -169,6 +219,14 @@ Fast lookup:
|
||||
- `packages/daemon/src/domain/ask-service.ts`: Context engineering evidence pack — gathers topology snapshot, transcript excerpts, chat history excerpts, restore metadata. Returns structured context for the agent to reason about. Does NOT call an external LLM. Exports: AskService, AskDeps. Related: `packages/daemon/src/domain/ps-projection.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/history-query.js`.
|
||||
- `packages/daemon/src/domain/chat-repository.ts`: Durable rig-scoped chat messages — CRUD for chat_messages table, monotonic ULID IDs, rig-scoped queries. Exports: ChatRepository, ChatMessage. Related: none (SQLite-only).
|
||||
|
||||
### Domain: Coordination Primitive (PL-004 Phase A)
|
||||
- `packages/daemon/src/domain/stream-store.ts`: L1 coordination stream — append-only intake/audit root. Idempotent emit on stream_item_id, chronological list with cursor pagination, soft archive. Exports: StreamStore, StreamItem, StreamEmitInput, StreamListOptions. Related: `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/queue-repository.ts`: L3 coordination queue — create, claim/unclaim, update with hot-potato strict-rejection, transactional handoff (close source + create new in one transaction), pod-fallback rerouting, overdue lookup. POC qitem_id format preserved. Exports: QueueRepository, QueueRepositoryError, QueueItem, QueueState, QueuePriority, QueueCreateInput, QueueUpdateInput, QueueHandoffInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-transition-log.js`, `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/queue-transition-log.ts`: Append-only state transition log for queue_items. No UPDATE / DELETE from domain code. Exports: QueueTransitionLog, QueueTransition, QueueTransitionInput. Related: `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/hot-potato-enforcer.ts`: Pure validator for the load-bearing hot-potato API contract — state=done requires closure_reason from the 6-value enum. Tier→SLA mapping for closure_required_at. Exports: CLOSURE_REASONS, ClosureReason, validateClosure, isClosureReason, computeClosureRequiredAt. Related: none.
|
||||
- `packages/daemon/src/domain/inbox-handler.ts`: Mailbox handler — authenticated drop (idempotent on inbox_id), absorb (promote pending entry to a queue_item), deny (record reason). Authentication is a pluggable constructor hook. Exports: InboxHandler, InboxHandlerError, InboxEntry, InboxState, InboxDropInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-repository.js`.
|
||||
- `packages/daemon/src/domain/outbox-handler.ts`: Sender-side outbox — symmetric to inbox; idempotent record, mark delivered/failed, list. No event-bus events emitted. Exports: OutboxHandler, OutboxHandlerError, OutboxEntry, OutboxDeliveryState, OutboxRecordInput. Related: none (SQLite-only).
|
||||
|
||||
### Routes
|
||||
- `packages/daemon/src/routes/adapters.ts`: Hono route module for adapters API endpoints. Exports: adaptersRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/routes/agents.ts`: Hono route module for AgentSpec validation. POST `/api/agents/validate` using `validateAgentSpecFromYaml`. Exports: agentsRoutes. Related: `packages/daemon/src/domain/spec-validation-service.js`.
|
||||
@@ -176,6 +234,7 @@ Fast lookup:
|
||||
- `packages/daemon/src/routes/bundles.ts`: Hono route module for dual-format bundle create/inspect/install. Supports legacy v1 package bundles and schema-version-2 pod bundles, including v2 inspect responses with `agents[]` and integrity data. Exports: bundleRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-assembler.js`, +7 more.
|
||||
- `packages/daemon/src/routes/discovery.ts`: Hono route module for discovery scan/list/detail plus Final Stretch flows: `draft-rig`, `bind`, and composite `adopt`. Exports: discoveryRoutes. Related: `packages/daemon/src/domain/claim-service.js`, `packages/daemon/src/domain/discovery-coordinator.js`, `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/draft-rig-generator.js`.
|
||||
- `packages/daemon/src/routes/down.ts`: Hono route module for down API endpoints. Exports: downRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rig-teardown.js`.
|
||||
- `packages/daemon/src/routes/env.ts`: Hono route module for rig-environment status, logs, and teardown. Refreshes env receipts best-effort and returns additive managed-app/runtime metadata (`hasServices`, `surfaces`, `receipt`). Exports: envRoutes. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/service-orchestrator.js`.
|
||||
- `packages/daemon/src/routes/events.ts`: Hono route module for events API endpoints. Exports: eventsRoute. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/routes/packages.ts`: Hono route module for the legacy package-install surface (`/validate`, `/plan`, `/install`, history, rollback). AgentSpec validation no longer lives here. Exports: packagesRoutes. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, +6 more.
|
||||
- `packages/daemon/src/routes/ps.ts`: Hono route module for ps API endpoints. Exports: psRoutes. Related: `packages/daemon/src/domain/ps-projection.js`.
|
||||
@@ -187,8 +246,10 @@ Fast lookup:
|
||||
- `packages/daemon/src/routes/transcripts.ts`: Hono route module for transcript access (`GET /:session/tail`, `/:session/grep`). Resolves session to rig/node, delegates to TranscriptStore. Exports: transcriptRoutes. Related: `packages/daemon/src/domain/transcript-store.js`, `packages/daemon/src/domain/rig-repository.js`.
|
||||
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
|
||||
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/stream.ts`: Hono route module for the PL-004 Phase A coordination L1 stream (`POST /emit`, `GET /list`, `GET /:id`, `POST /:id/archive`, `GET /watch` SSE). Host-scoped (no rigId). Exports: streamRoutes. Related: `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/queue.ts`: Hono route module for the PL-004 Phase A coordination L3 queue plus inbox/outbox (`POST /create`, `POST /:id/{claim,unclaim,update,handoff,fallback}`, `GET /list`, `GET /overdue`, `GET /:id`, `GET /:id/transitions`, inbox/outbox subroutes, `GET /watch` SSE). Surfaces hot-potato strict-rejection structured errors with validReasons enum. Exports: queueRoutes. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/inbox-handler.js`, `packages/daemon/src/domain/outbox-handler.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
|
||||
- `packages/daemon/src/routes/up.ts`: Hono route module for `/api/up`, routing direct rig specs, bundles, and rig-name restore into the staged bootstrap flow. Exports: upRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/up-command-router.js`.
|
||||
|
||||
@@ -197,7 +258,7 @@ Fast lookup:
|
||||
### Entrypoints And Infrastructure
|
||||
- `packages/cli/src/client.ts`: Shared HTTP client for talking to the daemon API, including raw JSON posts and text-body posts with optional extra headers such as `X-Rig-Root`. Exports: DaemonClient, DaemonConnectionError, DaemonResponse. Related: none.
|
||||
- `packages/cli/src/daemon-lifecycle.ts`: Starts, stops, inspects, and tails the local daemon process. Spawns the daemon with `process.execPath` and bypasses local `daemon.json` gating when `OPENRIG_URL` is set by probing remote `/healthz` directly. Exports: DaemonState, DaemonStatus, LOG_FILE, LifecycleDeps, OPENRIG_DIR, STATE_FILE, StartOptions, getDaemonPath, getDaemonStatus, readLogs, startDaemon, stopDaemon, tailLogs. Related: none.
|
||||
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (31 mounted command groups, including Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +18 more.
|
||||
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (40 mounted command groups, including env status/logs/down, `destroy`, and Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +19 more.
|
||||
- `packages/cli/src/mcp-server.ts`: Implements the CLI-hosted MCP server over stdio, wrapping 17 daemon-backed tools including node inventory, send/capture, bundle/spec validation, and chatroom. Exports: createMcpServer. Related: `packages/cli/src/client.js`.
|
||||
|
||||
### Commands
|
||||
@@ -209,6 +270,7 @@ Fast lookup:
|
||||
- `packages/cli/src/commands/daemon.ts`: Commander command module for `daemon`. Exports: daemonCommand, realDeps. Related: `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/discover.ts`: Commander command module for `discover`. Exports: discoverCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/down.ts`: Commander command module for `down`. Exports: downCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/env.ts`: Commander command module for `env` — rig environment status/logs/down for service-backed rigs and managed apps. Exports: envCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/commands/status.js`.
|
||||
- `packages/cli/src/commands/export.ts`: Commander command module for `export`. Exports: ExportDeps, exportCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/import.ts`: Commander command module for `import`. Locally parses YAML to detect pod-aware rigs, sends `X-Rig-Root` for pod-aware preflight/instantiate flows, and supports `--rig-root`. Exports: ImportDeps, importCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/mcp.ts`: Commander command module for `mcp`. Exports: mcpCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`, +1 more.
|
||||
@@ -219,9 +281,9 @@ Fast lookup:
|
||||
- `packages/cli/src/commands/rig.ts`: Commander command module for `rig`. Subcommands: `validate` (POST `/api/rigs/import/validate`), `preflight` (POST `/api/rigs/import/preflight` with `X-Rig-Root` header). Both support `--json`. Exports: RigDeps, rigCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/snapshot.ts`: Commander command module for `snapshot`. Exports: snapshotCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/status.ts`: Commander command module for `status`. Exports: StatusDeps, statusCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. `add` accepts single YAML files and full spec directories containing `rig.yaml` or `agent.yaml`. Help/examples and not-found guidance explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Help/examples now treat managed apps like `secrets-manager` as first-class launch targets. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
|
||||
- `packages/cli/src/commands/whoami.ts`: Commander command module for `whoami`. Resolves identity from explicit args, env vars, tmux metadata, and session-name fallback, then delegates to daemon `/api/whoami`. Exports: resolveIdentitySource, whoamiCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/adopt.ts`: Commander command module for `adopt` — materialize topology from a pod-aware RigSpec, then bind discovered live sessions according to `--bind` mappings. Exports: AdoptDeps, adoptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/import.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
@@ -230,6 +292,8 @@ Fast lookup:
|
||||
- `packages/cli/src/commands/capture.ts`: Commander command module for `capture` — capture agent pane content. Supports `--lines N` and `--json`. Exports: captureCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/broadcast.ts`: Commander command module for `broadcast` — send to all agents in a rig or pod. Reports per-session delivery status. Exports: broadcastCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/transcript.ts`: Commander command module for `transcript` — composable transcript access via `--tail N` and `--grep "pattern"`. Exports: transcriptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/stream.ts`: Commander command module for `rig stream` (PL-004 Phase A) — coordination L1 intake stream. Subcommands: `emit`, `list`, `show`, `archive`. Speaks only to the daemon HTTP API; does not touch POC filesystem state. Exports: streamCommand, StreamDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/queue.ts`: Commander command module for `rig queue` (PL-004 Phase A) — coordination L3 queue plus inbox/outbox. Subcommands: `create`, `claim`, `unclaim`, `update`, `handoff`, `fallback`, `show`, `transitions`, `list`, `overdue`, `inbox-drop`, `inbox-absorb`, `inbox-deny`, `inbox-pending`, `outbox-record`, `outbox-list`. Hot-potato strict-rejection enforced at daemon. Exports: queueCommand, QueueDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/config.ts`: Commander command module for `config` — read/write/reset config keys. Supports `--json`. Exports: configCommand. Related: `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/preflight.ts`: Commander command module for `preflight` — system readiness checks. Supports `--json`. Exports: preflightCommand. Related: `packages/cli/src/system-preflight.js`, `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/ask.ts`: Commander command module for `ask` — context evidence pack over transcripts + topology + chat. Supports `--json`. Exports: askCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
@@ -273,19 +337,20 @@ Fast lookup:
|
||||
### Components: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/components/Explorer.tsx`: Hierarchical tree navigator — rigs → pods → agents with status indicators. Primary navigation surface. Click rig loads graph, click node opens detail drawer. Exports: Explorer. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/SharedDetailDrawer.tsx`: Unified drawer surface keyed by `DrawerSelection`. Routes to rig, node, system, discovery, or specs panels without leaving the current workspace. Exports: DrawerSelection, SharedDetailDrawer. Related: `packages/ui/src/components/DiscoveryPanel.js`, `packages/ui/src/components/NodeDetailPanel.js`, `packages/ui/src/components/RigDetailPanel.js`, `packages/ui/src/components/SpecsPanel.js`, `packages/ui/src/components/SystemPanel.js`.
|
||||
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab. Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots/actions, and runtime drawer tabs (`Info`, conditional `Env`, `Chat Room`). Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/RigEnvPanel.tsx`: Runtime env surface for service-backed rigs. Shows overall env state, per-service health, health gates, surfaces, logs, and teardown action. Exports: RigEnvPanel. Related: `packages/ui/src/hooks/useRigEnv.js`.
|
||||
- `packages/ui/src/components/NodeDetailPanel.tsx`: Runtime-first node drawer. Shows rig/session/runtime identity, peers, directional edges, transcript helpers, compact spec summary, status, actions (tmux/cmux/resume/full-details), startup files, and recent events. Exports: NodeDetailPanel. Related: `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`, `packages/ui/src/lib/copy-text.js`, `packages/ui/src/lib/display-name.js`.
|
||||
- `packages/ui/src/components/NodeDetailPlaceholder.tsx`: Empty state for node detail when no node is selected. Exports: NodeDetailPlaceholder. Related: none.
|
||||
- `packages/ui/src/components/DiscoveryPanel.tsx`: Drawer-native discovery/adoption surface. Supports scan/list, draft-rig generation, placement-first bind/adopt, and pod/member targeting inside the shared drawer. Exports: DiscoveryPlacementTarget, DiscoveryPanel. Related: `packages/ui/src/hooks/useDiscovery.js`.
|
||||
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Lists library entries, opens review routes, and bridges into specs workspace tasks. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Presents a unified library list with `All / Apps / Rigs / Agents`, type/stability badges, richer managed-app rows, and review-route handoff. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/RigChatPanel.tsx`: Chat room tab in rig drawer — send messages, watch live stream, view history. Exports: RigChatPanel. Related: `packages/ui/src/hooks/useRigChat.js`.
|
||||
- `packages/ui/src/components/LiveIdentityDisplay.tsx`: Reusable runtime-identity subsection for peers, edges, transcript helpers, and compact spec summary. Used by the node drawer and full-details route. Exports: LiveIdentityDisplay. Related: none.
|
||||
- `packages/ui/src/components/LiveNodeDetails.tsx`: Explicit center-workspace route for deep inspection of one live node. Composes runtime identity plus reusable spec display components. Exports: LiveNodeDetails. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`.
|
||||
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
|
||||
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Supports a library-only managed-app Environment tab with compose preview, health gates, and surfaces. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
|
||||
- `packages/ui/src/components/AgentSpecDisplay.tsx`: Canonical AgentSpec display primitive used by preview, library, and live-details flows. Exports: AgentSpecDisplay. Related: none.
|
||||
- `packages/ui/src/components/RigSpecReview.tsx`: Route-level rig review page that consumes `RigSpecDisplay` and specs workspace state. Exports: RigSpecReview. Related: `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/AgentSpecReview.tsx`: Route-level agent review page that consumes `AgentSpecDisplay` and specs workspace state. Exports: AgentSpecReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Service-backed rigs surface managed-app copy, specialist identity, setup-prompt copy, and env-aware review details. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/SpecTopologyPreview.tsx`: Lightweight topology preview for structured rig review models. Exports: SpecTopologyPreview. Related: none.
|
||||
- `packages/ui/src/components/SystemPanel.tsx`: Drawer surface for system-wide event log/status tabs. Replaces the old status-bar role with a richer right-drawer system surface. Exports: SystemPanel. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/WorkspaceHome.tsx`: Landing page when no rig is selected. Entry point for explorer-first workflow. Exports: WorkspaceHome. Related: `packages/ui/src/components/AppShell.js`.
|
||||
@@ -315,16 +380,17 @@ Fast lookup:
|
||||
- `packages/ui/src/hooks/usePsEntries.ts`: `/api/ps` summary hook. Exports: PsEntry, usePsEntries. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEvents.ts`: Rig-scoped SSE subscription and invalidation hook. Exports: UseRigEventsResult, useRigEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useRigGraph.ts`: Rig graph fetch/transformation hook. Exports: useRigGraph. Related: none.
|
||||
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Exports: RigSummary, useRigSummary. Related: none.
|
||||
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Includes `hasServices` so runtime/UI can gate managed-app env surfaces. Exports: RigSummary, useRigSummary. Related: none.
|
||||
- `packages/ui/src/hooks/useSnapshots.ts`: Snapshot list hook for one rig. Exports: Snapshot, useSnapshots. Related: none.
|
||||
|
||||
### Hooks: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/hooks/useNodeInventory.ts`: Fetches node inventory for a rig from the canonical `GET /api/rigs/:rigId/nodes` endpoint. Exports: useNodeInventory. Related: none.
|
||||
- `packages/ui/src/hooks/useNodeDetail.ts`: Fetches extended node detail from `GET /api/rigs/:rigId/nodes/:logicalId`, including peers, edges, transcript helpers, and compact spec summary for the adopted-parity/live-identity drawer. Exports: NodeDetailData, useNodeDetail. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEnv.ts`: Fetches `GET /api/rigs/:rigId/env` for service-backed rig status, surfaces, and receipt data, with runtime-friendly refetch behavior. Exports: RigEnvData, useRigEnv. Related: none.
|
||||
- `packages/ui/src/hooks/useRigChat.ts`: Chat send/watch/history hooks for the rig chat panel. Exports: useRigChat. Related: none.
|
||||
- `packages/ui/src/hooks/useGlobalEvents.ts`: Global SSE subscription for explorer status updates. Exports: useGlobalEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results and `hasServices`-aware managed-app classification. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. The rig review type now carries managed-app services metadata and compose preview. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
|
||||
|
||||
### Lib Utilities
|
||||
- `packages/ui/src/lib/edge-styles.ts`: Edge-style resolver for graph links. Exports: EdgeStyleResult, getEdgeStyle. Related: none.
|
||||
@@ -338,4 +404,5 @@ Fast lookup:
|
||||
### Lib Utilities: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/lib/display-id.ts`: Short ULID display utility — uses ULID tail (not head) for better uniqueness in same-view contexts. UI-only, no backend changes. Exports: shortId. Related: none.
|
||||
- `packages/ui/src/lib/display-name.ts`: Pod name extraction from qualified logical IDs (e.g., "dev1" from "dev1.impl"). Exports: displayPodName, inferPodName. Related: none.
|
||||
- `packages/ui/src/lib/build-setup-prompt.ts`: Managed-app setup-prompt generator used by library review copy surfaces. Exports: buildSetupPrompt. Related: none.
|
||||
- `packages/ui/src/lib/copy-text.ts`: Clipboard copy utility for the "Copy tmux attach" and "Copy resume command" buttons. Exports: copyText. Related: none.
|
||||
@@ -0,0 +1,418 @@
|
||||
# AgentSpec Reference
|
||||
|
||||
Version: 1.0
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`
|
||||
|
||||
This is the canonical reference for the AgentSpec YAML format (`agent.yaml`). Every field, validation rule, and default documented here was traced from the actual parser and validator code.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Valid Example
|
||||
|
||||
```yaml
|
||||
name: my-agent
|
||||
version: "1.0"
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: []
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
|
||||
resources: {}
|
||||
|
||||
startup:
|
||||
files: []
|
||||
actions: []
|
||||
```
|
||||
|
||||
## Practical Example (implementer agent)
|
||||
|
||||
```yaml
|
||||
name: implementer
|
||||
version: "1.0"
|
||||
description: Implementation agent — writes code following TDD discipline
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
actions: []
|
||||
```
|
||||
|
||||
## Complete Example (all features)
|
||||
|
||||
```yaml
|
||||
name: vault-specialist
|
||||
version: "1.0"
|
||||
description: Vault specialist agent — manages HashiCorp Vault for this managed app
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
lifecycle:
|
||||
execution_mode: interactive_resident
|
||||
compaction_strategy: harness_native
|
||||
restore_policy: resume_if_possible
|
||||
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
- ref: local:../common-tools
|
||||
version: "2.0"
|
||||
|
||||
profiles:
|
||||
default:
|
||||
summary: Standard Vault operations profile
|
||||
preferences:
|
||||
runtime: claude-code
|
||||
uses:
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/profile-specific.md
|
||||
delivery_hint: send_text
|
||||
actions: []
|
||||
lifecycle:
|
||||
restore_policy: resume_if_possible
|
||||
|
||||
resources:
|
||||
skills:
|
||||
- id: vault-user
|
||||
path: skills/vault-user
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
hooks:
|
||||
- id: pre-commit
|
||||
path: hooks/pre-commit.sh
|
||||
runtimes: [claude-code]
|
||||
runtime_resources:
|
||||
- id: claude-settings
|
||||
path: runtime/claude-settings.fragment.json
|
||||
runtime: claude-code
|
||||
type: claude_settings_fragment
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
- path: startup/context.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
- path: guidance/optional-tips.md
|
||||
delivery_hint: guidance_merge
|
||||
required: false
|
||||
applies_on: [fresh_start]
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "Load vault-user skill and verify Vault health."
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Fields
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
|
||||
| `version` | string | yes | — | Spec version. Informational — not used for compatibility gating. |
|
||||
| `description` | string | no | — | Human-readable description. Shown in spec library and review surfaces. |
|
||||
| `defaults` | Defaults | no | — | Default runtime, model, and lifecycle settings. Applied when not overridden by the rig spec or profile. |
|
||||
| `imports` | Import[] | no | `[]` | Other AgentSpecs to import. Resources from imported specs become available for profile `uses` references. |
|
||||
| `profiles` | map<string, Profile> | no | `{}` | Named profiles. Each profile selects resources and can override startup/lifecycle. The rig spec member's `profile` field selects which profile to use. |
|
||||
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, hooks, runtime resources). These are the available pool that profiles select from via `uses`. |
|
||||
| `startup` | StartupBlock | no | `{ files: [], actions: [] }` | Agent-level startup files and actions. Applied to all profiles via the startup layering model. |
|
||||
|
||||
---
|
||||
|
||||
## Defaults
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
lifecycle:
|
||||
execution_mode: interactive_resident
|
||||
compaction_strategy: harness_native
|
||||
restore_policy: resume_if_possible
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `runtime` | string | no | — | Default runtime for this agent. Can be overridden by the rig spec member's `runtime` field. |
|
||||
| `model` | string | no | — | Default model. Can be overridden by the rig spec member's `model` field. |
|
||||
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
|
||||
|
||||
### Lifecycle Defaults
|
||||
|
||||
| Field | Type | Default | Allowed Values |
|
||||
|-------|------|---------|----------------|
|
||||
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
|
||||
| `compaction_strategy` | string | `harness_native` | `harness_native`, `pod_continuity` (`custom_prompt` is explicitly rejected in v1) |
|
||||
| `restore_policy` | string | `resume_if_possible` | `resume_if_possible`, `relaunch_fresh`, `checkpoint_only` |
|
||||
|
||||
---
|
||||
|
||||
## Imports
|
||||
|
||||
```yaml
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
- ref: local:../common-tools
|
||||
version: "2.0"
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `ref` | string | yes | Reference to another AgentSpec directory. Must start with `local:` (relative) or `path:` (absolute). The referenced directory must contain an `agent.yaml`. |
|
||||
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). |
|
||||
|
||||
### Import Resolution
|
||||
|
||||
- `local:` paths resolve relative to the importing spec's directory
|
||||
- `path:` paths are absolute filesystem paths
|
||||
- Imported resources become available for `uses` references in profiles
|
||||
- When referencing imported resources in `uses`, use qualified `namespace:id` format (e.g., `shared:openrig-user`)
|
||||
- Unqualified references (just `id`) resolve against the spec's own local resources first
|
||||
|
||||
### The Shared Import Pattern
|
||||
|
||||
Most built-in agents import the shared builtin spec:
|
||||
|
||||
```yaml
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
```
|
||||
|
||||
This gives access to the full pool of shared skills (openrig-user, mental-model-ha, development-team, etc.) and agents select the ones they need via profile `uses`.
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
default:
|
||||
summary: Standard operations profile
|
||||
preferences:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
uses:
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
startup:
|
||||
files: []
|
||||
actions: []
|
||||
lifecycle:
|
||||
restore_policy: resume_if_possible
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `summary` | string | no | — | Profile description. |
|
||||
| `preferences` | object | no | — | Runtime/model preferences for this profile. |
|
||||
| `preferences.runtime` | string | no | — | Preferred runtime. |
|
||||
| `preferences.model` | string | no | — | Preferred model. |
|
||||
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
|
||||
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
|
||||
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. |
|
||||
|
||||
### Uses
|
||||
|
||||
The `uses` block selects which resources from the `resources` pool (including imported resources) are active for this profile.
|
||||
|
||||
```yaml
|
||||
uses:
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
hooks: [pre-commit]
|
||||
runtime_resources: [claude-settings]
|
||||
```
|
||||
|
||||
Each array contains resource IDs. These can be:
|
||||
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
|
||||
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
|
||||
|
||||
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
skills:
|
||||
- id: vault-user
|
||||
path: skills/vault-user
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
subagents:
|
||||
- id: helper
|
||||
path: subagents/helper
|
||||
hooks:
|
||||
- id: pre-commit
|
||||
path: hooks/pre-commit.sh
|
||||
runtimes: [claude-code]
|
||||
runtime_resources:
|
||||
- id: claude-settings
|
||||
path: runtime/claude-settings.fragment.json
|
||||
runtime: claude-code
|
||||
type: claude_settings_fragment
|
||||
```
|
||||
|
||||
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
|
||||
|
||||
### Resource Categories
|
||||
|
||||
| Category | Fields | Description |
|
||||
|----------|--------|-------------|
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. |
|
||||
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
|
||||
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
|
||||
|
||||
*Fields marked with `*` are optional.
|
||||
|
||||
Recognized runtime resource types:
|
||||
- `claude_settings_fragment` — merge a JSON object into `<cwd>/.claude/settings.local.json`.
|
||||
- `claude_mcp_fragment` — merge a JSON object into `<cwd>/.mcp.json`.
|
||||
- `codex_config_fragment` — upsert a TOML fragment into `~/.codex/config.toml` inside an OpenRig-managed block.
|
||||
|
||||
Unknown runtime resource types are still copied to the runtime extension directory for agent-visible context.
|
||||
|
||||
### Resource Path Rules
|
||||
|
||||
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths)
|
||||
- Paths resolve relative to the agent spec's directory
|
||||
- Resource IDs must be unique within their category
|
||||
- Resource IDs are the identifiers used in `uses` references
|
||||
|
||||
### Guidance Resources
|
||||
|
||||
```yaml
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
target: CLAUDE.md # optional — where to merge
|
||||
merge: managed_block # optional — how to merge (default: managed_block)
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Resource identifier. |
|
||||
| `path` | string | yes | — | Relative path to the guidance file. |
|
||||
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
|
||||
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
|
||||
|
||||
---
|
||||
|
||||
## Startup Block
|
||||
|
||||
The startup block follows the same format as in the RigSpec (see `docs/reference/rig-spec.md` for full details on files and actions).
|
||||
|
||||
Agent-level startup is applied to all profiles. Profile-level startup is applied only when that profile is active. Both merge additively via the startup layering model.
|
||||
|
||||
### Delivery Hint Quick Reference
|
||||
|
||||
| Hint | When Delivered | Mechanism |
|
||||
|------|---------------|-----------|
|
||||
| `auto` | Before harness boot | System chooses based on file type |
|
||||
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
|
||||
| `skill_install` | Before harness boot | Installed to runtime skill directory |
|
||||
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
1. `name` and `version` are required non-empty strings.
|
||||
2. `imports` must be an array of objects with `ref` field.
|
||||
3. Import `ref` must start with `local:` (relative) or `path:` (absolute).
|
||||
4. Import `version` must be an exact version (no ranges).
|
||||
5. `profiles` must be a map (object), not an array.
|
||||
6. Profile `uses` references must resolve to declared resources (local or imported).
|
||||
7. Unqualified `uses` references that don't resolve to local resources require imports to be present.
|
||||
8. Qualified `uses` references must be in `namespace:id` format.
|
||||
9. All resource paths must be safe relative paths.
|
||||
10. Resource IDs must be unique within their category.
|
||||
11. `runtime_resources` entries require `runtime` field.
|
||||
12. Lifecycle `execution_mode` must be `interactive_resident`.
|
||||
13. Lifecycle `compaction_strategy` must be `harness_native` or `pod_continuity`.
|
||||
14. Lifecycle `restore_policy` must be `resume_if_possible`, `relaunch_fresh`, or `checkpoint_only`.
|
||||
15. Startup files and actions follow the same validation rules as in RigSpec.
|
||||
|
||||
---
|
||||
|
||||
## File System Layout
|
||||
|
||||
An agent spec directory follows this conventional layout:
|
||||
|
||||
```
|
||||
my-agent/
|
||||
agent.yaml # required — the AgentSpec
|
||||
guidance/
|
||||
role.md # role definition
|
||||
startup/
|
||||
context.md # boot-time grounding
|
||||
skills/
|
||||
my-skill/
|
||||
SKILL.md # skill content
|
||||
hooks/
|
||||
pre-commit.sh # hook script
|
||||
runtime/
|
||||
claude-settings.fragment.json # runtime-specific resource
|
||||
```
|
||||
|
||||
The only required file is `agent.yaml`. Everything else is referenced by paths in the spec and must exist at those relative paths.
|
||||
|
||||
---
|
||||
|
||||
## Shipped Examples
|
||||
|
||||
| Agent | Location | Imports | Profile Skills | Purpose |
|
||||
|-------|----------|---------|---------------|---------|
|
||||
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
|
||||
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, mental-model-ha, etc. | TDD implementation agent |
|
||||
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
|
||||
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
|
||||
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
|
||||
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, mental-model-ha, vault-user | Vault domain specialist |
|
||||
| `design` | `specs/agents/design/` | none | — | Product designer |
|
||||
@@ -0,0 +1,358 @@
|
||||
# Agent Startup Guide
|
||||
|
||||
Version: 0.2.0
|
||||
Last validated: 2026-04-11
|
||||
Applies to: OpenRig 0.1.x
|
||||
|
||||
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
|
||||
|
||||
---
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
Everything an agent receives at boot time falls into one of two categories:
|
||||
|
||||
### Category 1: Context Loading
|
||||
|
||||
The agent reads markdown files into its context window. This is the **primary mechanism** in OpenRig today and the one you should invest most of your authoring effort in.
|
||||
|
||||
Context loading shapes what the agent knows, believes, and is capable of:
|
||||
- Who it is (role, identity, pod membership)
|
||||
- How the team works (culture, communication norms, coordination protocols)
|
||||
- What the project is (codebase context, architecture, domain knowledge)
|
||||
- What it can do (skills, SOPs, operational procedures)
|
||||
- What its environment looks like (services, access credentials, tools)
|
||||
|
||||
### Category 2: Deterministic Configuration
|
||||
|
||||
The rig spec declaratively installs things into the agent's runtime environment:
|
||||
- Hooks (git hooks, pre-commit scripts)
|
||||
- Permissions (`.claude/settings.json` allowlists, approval modes)
|
||||
- MCPs (Model Context Protocol servers)
|
||||
- System dependencies (tools, packages)
|
||||
|
||||
See the **Current Support Matrix** section at the end of this guide for what is reliable today vs experimental.
|
||||
|
||||
**The v0.2.0 recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
|
||||
|
||||
---
|
||||
|
||||
## Context Loading: What Goes Where
|
||||
|
||||
### Skills vs Startup Files — The Key Distinction
|
||||
|
||||
**Skills** are reusable SOPs. They teach an agent HOW to do something — how to use OpenRig, how to do TDD, how to run a code review, how to operate Vault. Skills transfer across rigs. A skill you write once can be used by any agent in any rig.
|
||||
|
||||
**Startup/guidance files** are rig-specific and role-specific. They tell an agent WHO it is, WHAT it's working on, and HOW this particular team operates. They are authored per-rig and often per-pod or per-member.
|
||||
|
||||
| Put in a skill when... | Put in a startup/guidance file when... |
|
||||
|------------------------|---------------------------------------|
|
||||
| The knowledge is reusable across rigs | The knowledge is specific to this rig or project |
|
||||
| It teaches a procedure or methodology | It teaches identity, role, or team context |
|
||||
| It could be useful to any agent of this type | It's only useful to agents in this specific topology |
|
||||
| Examples: `openrig-user`, `test-driven-development`, `vault-user` | Examples: `role.md`, `CULTURE.md`, `startup/context.md` |
|
||||
|
||||
### The Belt-and-Suspenders Pattern for Skills
|
||||
|
||||
Skills are delivered to agents through two parallel paths:
|
||||
|
||||
1. **Spec declaration:** The agent spec's `resources.skills` + profile `uses.skills` ensures the skill files are projected to the agent's workspace (installed before harness boot)
|
||||
2. **Startup instruction:** The guidance/startup files tell the agent to actually read and load those skills
|
||||
|
||||
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
|
||||
|
||||
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, mental-model-ha" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
|
||||
|
||||
### The File Types
|
||||
|
||||
**Role guidance (`guidance/role.md`)**
|
||||
|
||||
Tells the agent who it is and what its responsibilities are:
|
||||
- Title and primary function
|
||||
- Specific responsibilities (bulleted list)
|
||||
- Working rhythm (how the agent operates day-to-day)
|
||||
- Principles (behavioral guidelines)
|
||||
- Relationship to other team members
|
||||
|
||||
This is the agent's identity document. Every agent should have one.
|
||||
|
||||
**Rig culture (`CULTURE.md`)**
|
||||
|
||||
The rig-wide constitution. Applied to ALL agents in the rig:
|
||||
- Communication norms (use `rig send`/chatroom, not raw tmux)
|
||||
- Coordination protocol (how work flows between pods)
|
||||
- Quality standards (what "done" means)
|
||||
- Commit/merge policy
|
||||
- Escalation rules
|
||||
|
||||
Think of this as the team operating manual that every team member reads on day one.
|
||||
|
||||
**Startup context (`startup/context.md`)**
|
||||
|
||||
Boot-time grounding information specific to this agent's operating environment:
|
||||
- Identity recovery instructions (`rig whoami --json`)
|
||||
- Environment details (service URLs, access credentials, ports)
|
||||
- System check instructions (what should be running, how to verify)
|
||||
- Role-specific delegation information (who to ask, who delegates to you)
|
||||
|
||||
This is especially important for managed-app specialists (e.g., a Vault specialist needs to know the Vault address and dev token).
|
||||
|
||||
**Project documentation**
|
||||
|
||||
Rig-level startup files that teach the agent about the project:
|
||||
- Architecture overview
|
||||
- Key conventions
|
||||
- Domain vocabulary
|
||||
- Recent context (what's been happening, what's in progress)
|
||||
|
||||
These go in rig-level or pod-level startup blocks and are delivered to relevant agents.
|
||||
|
||||
---
|
||||
|
||||
## The Layering Model
|
||||
|
||||
Startup content is merged additively through layers. Each layer adds to what the previous layers provided. Later layers do NOT replace earlier layers — they append.
|
||||
|
||||
### The Layers (in delivery order)
|
||||
|
||||
```
|
||||
1. Agent layer — from the AgentSpec's top-level startup block
|
||||
2. Profile layer — from the active profile's startup block
|
||||
3. Rig layer — from the RigSpec's top-level startup block
|
||||
4. Culture layer — from the RigSpec's culture_file
|
||||
5. Pod layer — from the pod's startup block
|
||||
6. Member layer — from the member's startup block in the RigSpec
|
||||
7. Operator layer — injected at runtime (openrig-start overlay, context collector, etc.)
|
||||
```
|
||||
|
||||
### What Each Layer Is For
|
||||
|
||||
| Layer | Authored by | Purpose | Example content |
|
||||
|-------|-------------|---------|-----------------|
|
||||
| Agent | Agent spec author | Core identity and capabilities that travel with this agent type | Role guidance, default skills |
|
||||
| Profile | Agent spec author | Profile-specific variations | Different skill sets for "default" vs "minimal" profiles |
|
||||
| Rig | Rig spec author | Rig-wide context for all agents | Team norms, project documentation |
|
||||
| Culture | Rig spec author | Rig constitution | `CULTURE.md` — communication, quality, operating philosophy |
|
||||
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
|
||||
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
|
||||
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md`, context collector |
|
||||
|
||||
### Practical Guidance
|
||||
|
||||
**Most rigs only need three layers:** agent (role.md), rig (culture), and operator (openrig-start). Start simple. Add pod and member layers only when agents in the same pod need different startup content.
|
||||
|
||||
**The culture layer is high-value and often skipped.** A rig without a `CULTURE.md` relies on agents to guess how the team communicates and coordinates. Write one. Even a short culture file dramatically improves team coherence.
|
||||
|
||||
**Member-level startup is for exceptions, not the rule.** If every member has its own startup block, the layering model is being used as a configuration dump. Refactor shared content up to the pod or rig level.
|
||||
|
||||
---
|
||||
|
||||
## Delivery Mechanisms
|
||||
|
||||
### How Files Reach the Agent
|
||||
|
||||
| Delivery Hint | When | How | Use For |
|
||||
|---------------|------|-----|---------|
|
||||
| `auto` | Before harness boot | System chooses | Default — let OpenRig decide |
|
||||
| `guidance_merge` | Before harness boot | Merged into `CLAUDE.md` / `AGENTS.md` as a managed block | Role guidance, culture, project context |
|
||||
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
|
||||
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
|
||||
|
||||
### Delivery Timing Matters
|
||||
|
||||
Files delivered via `guidance_merge` and `skill_install` happen BEFORE the agent's harness boots. The agent sees them immediately when it starts — they're part of the initial context.
|
||||
|
||||
Files delivered via `send_text` happen AFTER the harness is ready. The agent receives them as messages in its terminal. Use this for:
|
||||
- Identity grounding (agent reads and processes the instructions)
|
||||
- Instructions to load skills (the files are already projected, the message tells the agent to read them)
|
||||
- Context that should feel like an operator briefing, not pre-loaded content
|
||||
|
||||
### The `applies_on` Field
|
||||
|
||||
Each startup file and action specifies when it applies:
|
||||
- `fresh_start` — delivered on first launch only
|
||||
- `restore` — delivered on restore from snapshot
|
||||
- Default: `[fresh_start, restore]` (both)
|
||||
|
||||
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
|
||||
|
||||
---
|
||||
|
||||
## Deterministic Configuration
|
||||
|
||||
The AgentSpec and RigSpec allow declaring deterministic environment configuration:
|
||||
|
||||
### What the Spec Supports
|
||||
|
||||
**Hooks** (in `resources.hooks`):
|
||||
```yaml
|
||||
resources:
|
||||
hooks:
|
||||
- id: pre-commit
|
||||
path: hooks/pre-commit.sh
|
||||
runtimes: [claude-code]
|
||||
```
|
||||
Hooks are scripts copied to the agent's workspace. They can be git hooks, automation scripts, or environment setup.
|
||||
|
||||
**Runtime resources** (in `resources.runtime_resources`):
|
||||
```yaml
|
||||
resources:
|
||||
runtime_resources:
|
||||
- id: claude-settings
|
||||
path: runtime/claude-settings.fragment.json
|
||||
runtime: claude-code
|
||||
type: claude_settings_fragment
|
||||
```
|
||||
Runtime-specific configuration files projected into the agent's runtime environment.
|
||||
|
||||
**Startup actions** (in `startup.actions`):
|
||||
```yaml
|
||||
startup:
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "/install-mcp my-server"
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
```
|
||||
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
|
||||
|
||||
### Current Support Matrix (OpenRig 0.1.x)
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|------------|--------|-------|
|
||||
| Guidance file projection (`guidance_merge`) | **Supported** | Reliable. Primary delivery mechanism. |
|
||||
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
|
||||
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
|
||||
| Hook projection | **Experimental** | Files are copied but execution/integration varies by runtime. |
|
||||
| Runtime resource projection | **Supported for recognized fragments** | `claude_settings_fragment`, `claude_mcp_fragment`, and `codex_config_fragment` are applied to provider config. Unknown types are copied to runtime extension directories. |
|
||||
| Permission allowlisting | **Supported via harness config** | Core OpenRig boot adds the minimal Claude `Bash(rig:*)` baseline. Additional policy should be supplied through spec-selected runtime resource fragments or native Codex config profiles, not adapter hard-code. |
|
||||
| MCP installation | **Experimental** | Claude Code: `/mcp` interactive command or `claude mcp add` from CLI. Can also be described in startup files for agent self-configuration. Reliability depends on runtime TUI state. |
|
||||
| System dependency installation | **Not deterministic** | Describe in startup files; agent handles via shell commands. |
|
||||
| Recurring tasks / wake timers | **Runtime-dependent** | Claude Code supports recurring tasks via the `/loop` command. Codex does not have a confirmed equivalent. Orchestrators should include `/loop` instructions in startup for Claude Code agents. |
|
||||
|
||||
### The v0.2.0 Approach: Describe, Then Let the Agent Handle It
|
||||
|
||||
For anything beyond `guidance_merge`, `skill_install`, and `send_text`, the recommended approach is:
|
||||
|
||||
1. **Describe the desired end state** in a startup file (e.g., "You need these MCP servers configured, these permissions set, these hooks installed")
|
||||
2. **Include a system check** in the startup instructions ("Verify your environment: check that X is installed, Y is configured, Z is accessible")
|
||||
3. **Empower the agent to self-configure** ("If any of these are missing, install/configure them")
|
||||
4. **Optionally also declare it in the spec** for when deterministic support improves — the spec serves as the blueprint, and the startup file serves as the fallback instruction
|
||||
|
||||
This way, when deterministic support becomes fully reliable, the agent will boot up, see that everything is already set up (by the deterministic path), run its system check, confirm everything looks good, and proceed. Until then, the agent reads the instructions and handles the setup itself.
|
||||
|
||||
### Runtime Config Disclosure
|
||||
|
||||
OpenRig performs best-effort deterministic runtime configuration for managed sessions. Core bootstrap stays minimal; user/custom policy belongs in spec-selected runtime resources. These writes are intentionally invasive and should be disclosed plainly:
|
||||
|
||||
- Claude global config: `~/.claude/settings.json`
|
||||
Purpose: allow core `rig` commands without repeated Claude permission prompts.
|
||||
- Claude global state: `~/.claude.json`
|
||||
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
|
||||
- Claude project-local config: `.claude/settings.local.json`
|
||||
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
|
||||
- Claude project-local MCP config: `.mcp.json`
|
||||
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
|
||||
- Codex global config: `~/.codex/config.toml`
|
||||
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
|
||||
|
||||
Two important caveats:
|
||||
- these writes are best-effort and should still be paired with startup guidance so the local agent can verify and repair them if needed
|
||||
- already-running adopted sessions may need restart before they pick up newly written config
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
**Claude Code:**
|
||||
- Reads from `CLAUDE.md` and `.claude/` directory
|
||||
- Recurring tasks via the `/loop` command (e.g., `/loop 5m "check rig health"`) — this is NOT hooks; hooks are event-driven
|
||||
- MCP server management via `/mcp` interactive command or `claude mcp add` from CLI
|
||||
- Event-driven hooks system in `.claude/settings.json` — reacts to events like `PreToolUse`, `PostToolUse`, `SessionStart`, etc. (not time-based)
|
||||
- Permission allowlisting via `.claude/settings.json` (`permissions.allow`, `permissions.deny`, `permissions.defaultMode`)
|
||||
- Can self-configure MCP servers, permissions, and hooks from startup instructions
|
||||
|
||||
**Codex:**
|
||||
- Reads from `AGENTS.md` and `.agents/` directory
|
||||
- Recurring task support is limited — no confirmed equivalent of Claude Code's `/loop` command
|
||||
- MCP configuration mechanism differs from Claude Code
|
||||
- Approval mode controlled via launch flags (`-a`, `-s`, `--full-auto`)
|
||||
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
|
||||
|
||||
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
|
||||
|
||||
---
|
||||
|
||||
## Patterns and Anti-Patterns
|
||||
|
||||
### Good Patterns
|
||||
|
||||
**Start with role + culture + one skill**
|
||||
```
|
||||
agent.yaml → guidance/role.md
|
||||
rig.yaml → culture_file: CULTURE.md
|
||||
profile → uses.skills: [openrig-user]
|
||||
```
|
||||
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
|
||||
|
||||
**Separate project context from role**
|
||||
|
||||
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
|
||||
|
||||
**Tell the agent about its skills explicitly**
|
||||
|
||||
In a startup file or guidance, include a line like:
|
||||
```
|
||||
You have the following skills loaded: openrig-user, test-driven-development, mental-model-ha. Use them.
|
||||
```
|
||||
This prompts the agent to actually invoke the skills, not just have them as passive context.
|
||||
|
||||
**Include a system check in startup context**
|
||||
|
||||
```
|
||||
## System Check
|
||||
|
||||
After identity recovery, verify:
|
||||
1. `rig ps --nodes` shows your rig running
|
||||
2. `rig env status` shows services healthy (if applicable)
|
||||
3. Your working directory is correct
|
||||
4. Required tools are available (node, npm, git, etc.)
|
||||
|
||||
If anything is missing, fix it before starting work.
|
||||
```
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
**Dumping everything into one giant CLAUDE.md**
|
||||
|
||||
Don't. Use the layering model. Role goes in the agent spec. Culture goes in the rig spec. Project context goes in rig/pod startup files. If everything is in one file, you can't reuse any of it.
|
||||
|
||||
**Duplicating skill content in guidance files**
|
||||
|
||||
If you find yourself copying text from a skill into a guidance file, stop. Reference the skill instead. Skills are projected; guidance should point to them, not duplicate them.
|
||||
|
||||
**Using send_text for content that should be pre-loaded**
|
||||
|
||||
If the agent needs to know something BEFORE it starts reasoning, use `guidance_merge` (delivered before boot), not `send_text` (delivered after boot). `send_text` is for instructions the agent should process as a first task, not for foundational context.
|
||||
|
||||
**Over-specifying member-level startup**
|
||||
|
||||
If every member has a large startup block, the rig spec becomes a configuration dump. Refactor shared content up to the pod level. Member-level startup should be small overrides, not complete agent briefings.
|
||||
|
||||
**Relying on deterministic hooks for critical setup**
|
||||
|
||||
If your rig REQUIRES a hook to function and the hook installation fails silently, the agent won't know something is wrong. Always pair deterministic config with a startup instruction or system check that verifies the result.
|
||||
|
||||
---
|
||||
|
||||
## Authoring Checklist
|
||||
|
||||
When creating a new agent's startup experience:
|
||||
|
||||
- [ ] Write a `guidance/role.md` — who is this agent?
|
||||
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
|
||||
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
|
||||
- [ ] Choose skills from the shared pool via profile `uses`
|
||||
- [ ] Write a `startup/context.md` if the agent needs environment grounding
|
||||
- [ ] Include a system check in the startup context
|
||||
- [ ] Verify the agent spec validates: `rig agent validate agent.yaml`
|
||||
- [ ] Verify the rig spec validates: `rig spec validate rig.yaml`
|
||||
- [ ] Test by launching the rig and checking that the agent received the expected content
|
||||
@@ -0,0 +1,126 @@
|
||||
# Edge Types Reference
|
||||
|
||||
Version: 0.2.0
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Edges define relationships between members in a rig topology. They appear in two places:
|
||||
|
||||
- **Pod-local edges** — between members in the same pod (use unqualified member IDs)
|
||||
- **Cross-pod edges** — between members in different pods (use `pod.member` format)
|
||||
|
||||
See `docs/reference/rig-spec.md` for the YAML syntax.
|
||||
|
||||
## Edge Kinds
|
||||
|
||||
Five edge kinds are accepted by the validator:
|
||||
|
||||
| Kind | Accepted | Has Runtime Behavior | Description |
|
||||
|------|----------|---------------------|-------------|
|
||||
| `delegates_to` | yes | **yes — affects launch order** | Source delegates work to target |
|
||||
| `spawned_by` | yes | **yes — affects launch order** | Target was spawned by source |
|
||||
| `can_observe` | yes | no | Source can observe target's output |
|
||||
| `collaborates_with` | yes | no | Peer collaboration |
|
||||
| `escalates_to` | yes | no | Source escalates to target |
|
||||
|
||||
## What Edges Actually Do Today (OpenRig 0.1.x)
|
||||
|
||||
### Launch ordering
|
||||
|
||||
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
|
||||
|
||||
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
|
||||
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
|
||||
|
||||
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails.
|
||||
|
||||
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
|
||||
|
||||
### Graph visualization
|
||||
|
||||
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
|
||||
|
||||
### Identity projection
|
||||
|
||||
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
|
||||
|
||||
### Attach hint heuristic
|
||||
|
||||
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) uses edges to prefer the orchestrator as the default attach target. The heuristic looks for the first node with `delegates_to` outgoing edges.
|
||||
|
||||
## What Edges Do NOT Do Today
|
||||
|
||||
Edges do NOT currently:
|
||||
|
||||
- **Route messages** — `rig send` can target any session, regardless of edges
|
||||
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
|
||||
- **Control permissions** — there's no edge-based access control
|
||||
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
|
||||
|
||||
These are aspirational capabilities. The edge vocabulary is intentionally richer than the current runtime behavior so that the topology captures design intent even before the runtime enforces it.
|
||||
|
||||
## Design Intent (Why We Have Five Kinds)
|
||||
|
||||
The five kinds represent a taxonomy of how agents relate to each other in a working team:
|
||||
|
||||
**`delegates_to`** — the most common edge. An orchestrator delegates to an implementer. A lead delegates to a worker. This is the primary workflow direction. When an orchestrator sends a task, it uses `rig send` to the session it `delegates_to`.
|
||||
|
||||
**`spawned_by`** — for hierarchical launch relationships. A subagent spawned by a parent process. Less common in current topologies.
|
||||
|
||||
**`can_observe`** — the review/oversight relationship. A reviewer observes an implementer's work. The reviewer can `rig capture` and `rig transcript` the implementation session. This edge communicates intent: "I'm watching your output."
|
||||
|
||||
**`collaborates_with`** — peer relationships. Two agents working side-by-side at the same level of the hierarchy. Neither delegates to the other.
|
||||
|
||||
**`escalates_to`** — the reverse of delegation. When a worker hits a decision it can't make, it escalates to a lead. This is less common in current topologies but represents a real coordination pattern.
|
||||
|
||||
## Choosing Edge Kinds
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Orchestrator → workers:**
|
||||
```yaml
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: orch.lead
|
||||
to: dev.impl
|
||||
```
|
||||
|
||||
**Reviewer → implementer:**
|
||||
```yaml
|
||||
edges:
|
||||
- kind: can_observe
|
||||
from: rev.r1
|
||||
to: dev.impl
|
||||
```
|
||||
|
||||
**Implementation pair:**
|
||||
```yaml
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl
|
||||
to: qa
|
||||
```
|
||||
|
||||
### When You're Not Sure
|
||||
|
||||
If you're unsure which edge kind to use:
|
||||
|
||||
1. Does one agent give work to the other? → `delegates_to`
|
||||
2. Does one agent watch the other's output? → `can_observe`
|
||||
3. Are they equals working together? → `collaborates_with`
|
||||
4. Does one report problems up? → `escalates_to`
|
||||
5. Did one create the other? → `spawned_by`
|
||||
|
||||
If none fit, `can_observe` is the safest default — it documents the relationship without implying a workflow direction and doesn't affect launch ordering.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
|
||||
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
|
||||
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
|
||||
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
|
||||
5. Self-edges are not validated at the schema level but are not meaningful
|
||||
@@ -0,0 +1,367 @@
|
||||
# RigBundle Reference
|
||||
|
||||
Version: 2 (pod-aware)
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/bundle-types.ts`, `packages/daemon/src/domain/bundle-archive.ts`, `packages/daemon/src/domain/pod-bundle-assembler.ts`
|
||||
|
||||
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
|
||||
|
||||
---
|
||||
|
||||
## Archive Format
|
||||
|
||||
A `.rigbundle` file is a gzip-compressed tar archive (`.tar.gz`) with a fixed structure.
|
||||
|
||||
### File extension
|
||||
|
||||
The archive MUST have the `.rigbundle` extension. The packer rejects output paths that don't end with `.rigbundle`.
|
||||
|
||||
### Sibling digest file
|
||||
|
||||
Every `.rigbundle` has a sibling `.rigbundle.sha256` file containing the SHA-256 hex digest of the archive. This detects corruption during transfer. The unpacker verifies this digest before extraction.
|
||||
|
||||
Example:
|
||||
```
|
||||
my-rig.rigbundle — the archive
|
||||
my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
|
||||
```
|
||||
|
||||
### Determinism
|
||||
|
||||
The packer produces deterministic output:
|
||||
- Files are sorted alphabetically
|
||||
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
|
||||
- Portable mode normalizes uid/gid/mode
|
||||
- Maximum gzip compression (level 9)
|
||||
|
||||
This means the same inputs always produce the same archive hash.
|
||||
|
||||
---
|
||||
|
||||
## Archive Layout
|
||||
|
||||
```
|
||||
bundle.yaml — manifest (required)
|
||||
rig.yaml — the RigSpec (required, may be rewritten)
|
||||
CULTURE.md — culture file (if declared in rig spec)
|
||||
SETUP.md — documentation (if declared in rig spec docs field)
|
||||
agents/
|
||||
agent-name/
|
||||
agent.yaml — AgentSpec
|
||||
guidance/
|
||||
role.md — guidance files
|
||||
skills/
|
||||
skill-name/
|
||||
SKILL.md — skill files
|
||||
startup/
|
||||
context.md — startup files
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- `bundle.yaml` is the manifest — always present, always at the root
|
||||
- `rig.yaml` is the rig spec — rewritten during assembly with vendored `agent_ref` paths
|
||||
- Agent directories are vendored copies of the original agent specs with all their resources
|
||||
- Import refs are rewritten from the original `local:` or `path:` paths to bundle-relative `local:` paths
|
||||
- All file paths within the archive are safe relative paths (no `..`, no absolute, no symlinks)
|
||||
|
||||
---
|
||||
|
||||
## Manifest (`bundle.yaml`)
|
||||
|
||||
The manifest is a YAML file at the archive root that describes the bundle contents.
|
||||
|
||||
### Schema Version 2 (pod-aware, current)
|
||||
|
||||
```yaml
|
||||
schema_version: 2
|
||||
name: my-bundle
|
||||
version: "0.1.0"
|
||||
created_at: "2026-04-11T22:32:48.570Z"
|
||||
rig_spec: rig.yaml
|
||||
agents:
|
||||
- name: pm-lead
|
||||
version: "1.0"
|
||||
path: agents/pm-lead
|
||||
original_ref: "local:agents/pm-lead"
|
||||
hash: "ed4cff20..."
|
||||
import_entries: []
|
||||
- name: researcher
|
||||
version: "1.0"
|
||||
path: agents/researcher
|
||||
original_ref: "local:agents/researcher"
|
||||
hash: "11f8a077..."
|
||||
import_entries:
|
||||
- name: shared
|
||||
version: "1.0"
|
||||
path: agents/shared
|
||||
original_ref: "local:../../shared"
|
||||
hash: "abc123..."
|
||||
culture_file: CULTURE.md
|
||||
integrity:
|
||||
algorithm: sha256
|
||||
files:
|
||||
rig.yaml: "b80c0674..."
|
||||
CULTURE.md: "9354361b..."
|
||||
agents/pm-lead/agent.yaml: "ed4cff20..."
|
||||
# ... every file in the archive
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `schema_version` | number | yes | Must be `2` for pod-aware bundles. |
|
||||
| `name` | string | yes | Bundle name. |
|
||||
| `version` | string | yes | Bundle version. |
|
||||
| `created_at` | string | yes | ISO-8601 timestamp of creation. |
|
||||
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
|
||||
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
|
||||
| `culture_file` | string | no | Relative path to the culture file if present. |
|
||||
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. |
|
||||
|
||||
### Agent Entry Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | yes | Agent name (from agent.yaml). |
|
||||
| `version` | string | no | Agent version. |
|
||||
| `path` | string | yes | Relative path to the vendored agent directory. Safe relative path. |
|
||||
| `original_ref` | string | yes | The original `agent_ref` before rewriting. |
|
||||
| `hash` | string | yes | SHA-256 hash of the agent.yaml content. |
|
||||
| `import_entries` | ImportEntry[] | yes | Vendored imports for this agent (may be empty). |
|
||||
|
||||
### Import Entry Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | yes | Imported agent name. |
|
||||
| `version` | string | yes | Imported agent version. |
|
||||
| `path` | string | yes | Relative path to the vendored import within the archive. |
|
||||
| `original_ref` | string | yes | The original import ref before rewriting. |
|
||||
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
|
||||
|
||||
### Integrity Section
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `algorithm` | string | yes | Must be `sha256`. |
|
||||
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive (except `bundle.yaml` itself) should be listed. |
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
Bundle integrity provides **self-consistency verification, not authenticity**.
|
||||
|
||||
- The sibling `.sha256` file detects corruption during transfer
|
||||
- The per-file integrity hashes detect tampering of individual files within the archive
|
||||
- Neither mechanism authenticates the bundle author
|
||||
|
||||
An attacker who can rewrite the full bundle + digest can bypass verification. Users must trust the source they obtained the bundle from. This is the same trust model as unsigned npm packages and Docker images.
|
||||
|
||||
Future enhancement: cryptographic signing (Ed25519) for author authentication.
|
||||
|
||||
---
|
||||
|
||||
## Safety Guarantees
|
||||
|
||||
The unpacker enforces these safety rules before extraction:
|
||||
|
||||
1. **No symlinks or hardlinks** — `SymbolicLink` and `Link` entries are rejected
|
||||
2. **No absolute paths** — entries starting with `/` are rejected
|
||||
3. **No path traversal** — entries containing `..` segments are rejected
|
||||
4. **Digest verification** — archive SHA-256 must match the sibling `.sha256` file
|
||||
5. **Content integrity** — after extraction, per-file hashes are verified against the manifest
|
||||
|
||||
If any check fails, extraction is aborted and an error is thrown.
|
||||
|
||||
---
|
||||
|
||||
## CLI Surface
|
||||
|
||||
### Create a bundle
|
||||
|
||||
```bash
|
||||
rig bundle create <spec-path> -o <output.rigbundle> [--rig-root <dir>] [--name <name>] [--bundle-version <ver>]
|
||||
```
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `<spec-path>` | yes | — | Path to the rig spec YAML file. |
|
||||
| `-o, --output` | yes | — | Output path. Must end with `.rigbundle`. |
|
||||
| `--rig-root` | no | spec directory | Root directory for resolving `agent_ref` and other relative paths. |
|
||||
| `--name` | no | `my-bundle` | Bundle name in the manifest. |
|
||||
| `--bundle-version` | no | `0.1.0` | Bundle version in the manifest. |
|
||||
|
||||
The create command:
|
||||
1. Validates the rig spec
|
||||
2. Resolves all `agent_ref` paths and their imports
|
||||
3. Vendors all agent specs, resources, and startup files into a staging directory
|
||||
4. Rewrites `agent_ref` paths to bundle-relative `local:` refs
|
||||
5. Collects culture file, docs files, and rig-level startup files
|
||||
6. Computes per-file integrity hashes
|
||||
7. Writes the manifest (`bundle.yaml`)
|
||||
8. Packs into a deterministic `.tar.gz`
|
||||
9. Writes the sibling `.sha256` digest
|
||||
|
||||
### Inspect a bundle
|
||||
|
||||
```bash
|
||||
rig bundle inspect <bundle-path> [--json]
|
||||
```
|
||||
|
||||
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install the bundle.
|
||||
|
||||
### Install a bundle
|
||||
|
||||
```bash
|
||||
rig bundle install <bundle-path> [--plan] [--yes] [--target <root>] [--json]
|
||||
```
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
|
||||
| `--plan` | no | `false` | Preview the bootstrap plan without installing or launching. |
|
||||
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
|
||||
| `--target <root>` | yes in apply mode | — | Target root directory for package installation. Required unless `--plan` is used. |
|
||||
| `--json` | no | `false` | Emit machine-readable JSON. |
|
||||
|
||||
Extracts the bundle, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
|
||||
|
||||
### Launch directly
|
||||
|
||||
```bash
|
||||
rig up <bundle-path> [--target <root>] [--cwd <dir>]
|
||||
```
|
||||
|
||||
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
|
||||
|
||||
- `--target <root>` controls where packaged files are installed
|
||||
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory
|
||||
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
|
||||
|
||||
---
|
||||
|
||||
## Assembly Process
|
||||
|
||||
When `rig bundle create` runs, the `PodBundleAssembler` performs these steps:
|
||||
|
||||
1. **Parse and validate** the rig spec
|
||||
2. **Collect rig-level files:**
|
||||
- Culture file (if `culture_file` is set)
|
||||
- Docs files (if `docs` array is set) — **required: missing docs fail assembly**
|
||||
- Rig-level startup files
|
||||
- Pod-level and member-level startup files
|
||||
3. **For each member's `agent_ref`:**
|
||||
- Resolve the ref to an agent spec directory
|
||||
- Copy the agent spec and all its resources (skills, guidance, hooks, startup, runtime resources)
|
||||
- Recursively resolve and copy imports
|
||||
- Record the agent entry in the manifest with its hash
|
||||
- Rewrite the ref to a bundle-relative `local:` path
|
||||
4. **Write the rewritten rig spec** to the staging directory
|
||||
5. **Compute integrity** — SHA-256 of every file in the staging directory
|
||||
6. **Write the manifest** (`bundle.yaml`) with all entries and integrity
|
||||
7. **Pack** the staging directory into a deterministic tar.gz
|
||||
|
||||
### Terminal nodes
|
||||
|
||||
Members with `agent_ref: "builtin:terminal"` are bundle-native sentinels. They are not vendored — the runtime handles them directly.
|
||||
|
||||
### Deduplication
|
||||
|
||||
If multiple members reference the same agent spec (same resolved path), the spec is vendored once and all members' refs are rewritten to the same bundle-relative path.
|
||||
|
||||
### Import resolution
|
||||
|
||||
When an agent spec has `imports`, each import is resolved, vendored into the bundle, and the import refs in the vendored agent.yaml are rewritten to bundle-relative `local:` paths. Import entries are recorded in the manifest's agent entry.
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
### Manifest validation (schema version 2)
|
||||
|
||||
1. `schema_version` must be `2`
|
||||
2. `name` is required non-empty string
|
||||
3. `version` is required non-empty string
|
||||
4. `created_at` is required non-empty string
|
||||
5. `rig_spec` is required and must be a safe relative path
|
||||
6. `agents` must be an array
|
||||
7. Each agent must have `name`, `path` (safe relative), and `hash`
|
||||
8. Integrity `algorithm` must be `sha256`
|
||||
9. Integrity `files` must be a non-empty map of safe-relative-path → 64-char hex hash
|
||||
|
||||
### Archive safety (enforced on unpack)
|
||||
|
||||
1. No symlinks or hardlinks
|
||||
2. No absolute paths
|
||||
3. No `..` path traversal
|
||||
4. Archive digest must match sibling `.sha256`
|
||||
5. Per-file content hashes must match integrity section
|
||||
|
||||
### Assembly validation
|
||||
|
||||
1. Rig spec must validate
|
||||
2. All `agent_ref` paths must resolve to valid agent specs
|
||||
3. All declared `docs` files must exist on disk (missing docs fail assembly)
|
||||
4. Culture file and startup files are collected best-effort (missing = skipped)
|
||||
|
||||
---
|
||||
|
||||
## Legacy Bundles (Schema Version 1)
|
||||
|
||||
Schema version 1 bundles are the pre-reboot format using flat-node rig specs and package-based bundling. They are still supported for backward compatibility but should not be created for new rigs.
|
||||
|
||||
Key differences from v2:
|
||||
- `schema_version: 1` in the manifest
|
||||
- `packages` array instead of `agents` array
|
||||
- Package entries have `original_source` instead of `original_ref`
|
||||
- No `import_entries` in package entries
|
||||
- Legacy rig spec format (flat nodes, not pods)
|
||||
|
||||
---
|
||||
|
||||
## Example: Creating and Using a Bundle
|
||||
|
||||
### Create
|
||||
|
||||
```bash
|
||||
# From the rig directory
|
||||
rig bundle create rig.yaml -o my-team.rigbundle --rig-root . --name my-team --bundle-version 1.0.0
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Bundle created: my-team.rigbundle
|
||||
Name: my-team v1.0.0
|
||||
Hash: a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
### Inspect
|
||||
|
||||
```bash
|
||||
rig bundle inspect my-team.rigbundle
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Bundle: my-team v1.0.0
|
||||
Digest valid: true
|
||||
Integrity: PASS
|
||||
```
|
||||
|
||||
### Install and launch
|
||||
|
||||
```bash
|
||||
cd ~/projects/my-project
|
||||
rig up /path/to/my-team.rigbundle
|
||||
```
|
||||
|
||||
Equivalent explicit form:
|
||||
|
||||
```bash
|
||||
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
|
||||
```
|
||||
|
||||
The bundle is extracted to a temporary directory, integrity is verified, packaged files are installed into the target root, and the rig is bootstrapped with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
|
||||
@@ -0,0 +1,479 @@
|
||||
# RigSpec Reference
|
||||
|
||||
Version: 0.2 (pod-aware)
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`
|
||||
|
||||
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Valid Example
|
||||
|
||||
```yaml
|
||||
version: "0.2"
|
||||
name: my-rig
|
||||
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: "local:agents/impl"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
edges: []
|
||||
|
||||
edges: []
|
||||
```
|
||||
|
||||
## Complete Example (all features)
|
||||
|
||||
```yaml
|
||||
version: "0.2"
|
||||
name: my-product-team
|
||||
summary: A full product squad with orchestration, development, and review pods.
|
||||
|
||||
culture_file: culture/CULTURE.md
|
||||
|
||||
docs:
|
||||
- path: SETUP.md
|
||||
- path: README.md
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/team-norms.md
|
||||
delivery_hint: guidance_merge
|
||||
required: true
|
||||
actions: []
|
||||
|
||||
services:
|
||||
kind: compose
|
||||
compose_file: docker-compose.yaml
|
||||
project_name: my-product
|
||||
profiles: [core]
|
||||
down_policy: down
|
||||
wait_for:
|
||||
- url: http://127.0.0.1:5432/health
|
||||
- service: redis
|
||||
condition: healthy
|
||||
surfaces:
|
||||
urls:
|
||||
- name: App
|
||||
url: http://127.0.0.1:3000
|
||||
commands:
|
||||
- name: psql
|
||||
command: "psql postgresql://app:[email protected]:5432/app"
|
||||
checkpoints:
|
||||
- id: postgres
|
||||
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
|
||||
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
|
||||
|
||||
pods:
|
||||
- id: orch
|
||||
label: Orchestration
|
||||
members:
|
||||
- id: lead
|
||||
agent_ref: "local:agents/orchestrator"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
- id: peer
|
||||
agent_ref: "local:agents/orchestrator"
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: "."
|
||||
edges: []
|
||||
|
||||
- id: dev
|
||||
label: Development
|
||||
summary: Implementation and quality assurance pair.
|
||||
continuity_policy:
|
||||
enabled: true
|
||||
sync_triggers: [pre_compaction, pre_shutdown]
|
||||
artifacts:
|
||||
session_log: true
|
||||
restore_brief: true
|
||||
restore_protocol:
|
||||
peer_driven: true
|
||||
verify_via_quiz: false
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/dev-sop.md
|
||||
delivery_hint: guidance_merge
|
||||
required: true
|
||||
actions: []
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: "local:agents/impl"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
label: "Implementation Lead"
|
||||
model: claude-opus-4-6
|
||||
restore_policy: resume_if_possible
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/impl-specific.md
|
||||
delivery_hint: send_text
|
||||
required: false
|
||||
applies_on: [fresh_start]
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "Load the implementation-pair skill and begin."
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
- id: qa
|
||||
agent_ref: "local:agents/qa"
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: "."
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl
|
||||
to: qa
|
||||
|
||||
- id: rev
|
||||
label: Review
|
||||
members:
|
||||
- id: r1
|
||||
agent_ref: "local:agents/reviewer"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
- id: r2
|
||||
agent_ref: "local:agents/reviewer"
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: "."
|
||||
edges: []
|
||||
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: orch.lead
|
||||
to: dev.impl
|
||||
- kind: delegates_to
|
||||
from: orch.peer
|
||||
to: dev.qa
|
||||
- kind: can_observe
|
||||
from: rev.r1
|
||||
to: dev.impl
|
||||
- kind: can_observe
|
||||
from: rev.r2
|
||||
to: dev.qa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Fields
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `version` | string | yes | — | Must be `"0.2"` for pod-aware specs. |
|
||||
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
|
||||
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
|
||||
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
|
||||
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
|
||||
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
|
||||
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
|
||||
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
|
||||
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
|
||||
|
||||
---
|
||||
|
||||
## Pod
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
|
||||
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
|
||||
| `summary` | string | no | — | Pod description. |
|
||||
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
|
||||
| `startup` | StartupBlock | no | — | Pod-level startup files and actions. Applied to all members in this pod via the startup layering model. |
|
||||
| `members` | Member[] | yes | — | At least one member required (enforced by pods needing content). |
|
||||
| `edges` | PodLocalEdge[] | no | `[]` | Edges between members within this pod. Must use unqualified member IDs (not `pod.member`). |
|
||||
|
||||
### Pod ID Rules
|
||||
|
||||
- Must not contain dots (`.`)
|
||||
- Must be unique across all pods in the rig
|
||||
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
|
||||
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
|
||||
|
||||
---
|
||||
|
||||
## Member
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
|
||||
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
|
||||
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
|
||||
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
|
||||
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
|
||||
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
|
||||
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
|
||||
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
|
||||
|
||||
### Terminal Nodes
|
||||
|
||||
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
|
||||
|
||||
```yaml
|
||||
runtime: terminal
|
||||
agent_ref: "builtin:terminal"
|
||||
profile: none
|
||||
```
|
||||
|
||||
All three must be present together. Any partial combination is a validation error.
|
||||
|
||||
### agent_ref Rules
|
||||
|
||||
- Must start with `local:` or `path:`
|
||||
- `local:` paths are relative to the rig spec file's directory (the rig root)
|
||||
- `path:` paths are absolute filesystem paths
|
||||
- The referenced path must contain an `agent.yaml` file
|
||||
- Exception: `builtin:terminal` for terminal nodes
|
||||
|
||||
### Session Naming
|
||||
|
||||
The canonical session name is derived from the pod ID, member ID, and rig name:
|
||||
|
||||
```
|
||||
{podId}-{memberId}@{rigName}
|
||||
```
|
||||
|
||||
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
|
||||
|
||||
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
|
||||
|
||||
---
|
||||
|
||||
## Edges
|
||||
|
||||
### Edge Kinds
|
||||
|
||||
| Kind | Meaning | Use When |
|
||||
|------|---------|----------|
|
||||
| `delegates_to` | Source delegates work to target. Constrains launch order. | Orchestrator → implementer, lead → worker |
|
||||
| `spawned_by` | Target was spawned by source. Constrains launch order. | Parent → child in hierarchical topologies |
|
||||
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
|
||||
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
|
||||
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
|
||||
|
||||
### Pod-Local Edges
|
||||
|
||||
Edges within a pod use **unqualified member IDs** (just the member `id`, not `pod.member`):
|
||||
|
||||
```yaml
|
||||
pods:
|
||||
- id: dev
|
||||
members:
|
||||
- id: impl
|
||||
# ...
|
||||
- id: qa
|
||||
# ...
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl # NOT dev.impl
|
||||
to: qa # NOT dev.qa
|
||||
```
|
||||
|
||||
Both `from` and `to` must reference members that exist in the same pod.
|
||||
|
||||
### Cross-Pod Edges
|
||||
|
||||
Edges between pods use **fully-qualified `pod.member` IDs**:
|
||||
|
||||
```yaml
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: orch.lead # pod.member format
|
||||
to: dev.impl # pod.member format
|
||||
```
|
||||
|
||||
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
|
||||
|
||||
---
|
||||
|
||||
## Startup Block
|
||||
|
||||
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see `docs/reference/startup-layering.md`).
|
||||
|
||||
### Files
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
|
||||
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
|
||||
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
#### Delivery Hints
|
||||
|
||||
| Hint | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | System chooses based on file type and context. |
|
||||
| `guidance_merge` | Merged into the runtime's guidance file (`CLAUDE.md` or `AGENTS.md`) as a managed block. Delivered before harness boot. |
|
||||
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
|
||||
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
|
||||
|
||||
### Actions
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`. Note: `shell` is explicitly NOT supported in v1. |
|
||||
| `value` | string | yes | — | The command or text to send. |
|
||||
| `phase` | string | no | `after_files` | When to execute. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). |
|
||||
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
---
|
||||
|
||||
## Services Block
|
||||
|
||||
The services block is optional. When present, services boot before any agent node launches. If service health checks fail, agent launch is blocked.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `kind` | string | yes | — | Service backend. Only `compose` is supported in v1. |
|
||||
| `compose_file` | string | yes | — | Relative path to the Docker Compose file. Must be a safe relative path. Resolved relative to rig root. |
|
||||
| `project_name` | string | no | derived from rig name | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. If omitted, derived by sanitizing the rig name. |
|
||||
| `profiles` | string[] | no | — | Compose profiles to activate. |
|
||||
| `down_policy` | string | no | `down` | What happens on `rig down`. One of: `leave_running`, `down`, `down_and_volumes`. |
|
||||
| `wait_for` | WaitTarget[] | no | — | Health targets that must pass before agent launch. |
|
||||
| `surfaces` | Surfaces | no | — | Metadata about accessible URLs and commands. Not executed — informational only. |
|
||||
| `checkpoints` | CheckpointHook[] | no | — | Shell commands for checkpoint export/import during snapshot/restore. |
|
||||
|
||||
### Wait Targets
|
||||
|
||||
Each target must define exactly one of `service`, `url`, or `tcp`:
|
||||
|
||||
```yaml
|
||||
wait_for:
|
||||
# HTTP probe — hits the URL, expects 2xx
|
||||
- url: http://127.0.0.1:8200/v1/sys/health
|
||||
|
||||
# TCP probe — connects to host:port
|
||||
- tcp: "127.0.0.1:5432"
|
||||
|
||||
# Compose health check — requires Docker health to report "healthy"
|
||||
- service: postgres
|
||||
condition: healthy
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `url` | string | one of three | HTTP URL to probe. |
|
||||
| `tcp` | string | one of three | `host:port` for TCP probe. |
|
||||
| `service` | string | one of three | Compose service name. Requires `condition: healthy`. |
|
||||
| `condition` | string | only with `service` | Must be `healthy`. Only valid with `service` targets. |
|
||||
|
||||
### Surfaces
|
||||
|
||||
```yaml
|
||||
surfaces:
|
||||
urls:
|
||||
- name: Vault UI
|
||||
url: http://127.0.0.1:8200/ui
|
||||
commands:
|
||||
- name: Vault status
|
||||
command: "vault status -address=http://127.0.0.1:8200"
|
||||
```
|
||||
|
||||
Surfaces are metadata only. They are displayed in the UI and in `rig env status` output but are NOT executed by OpenRig.
|
||||
|
||||
### Checkpoint Hooks
|
||||
|
||||
```yaml
|
||||
checkpoints:
|
||||
- id: postgres
|
||||
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
|
||||
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | string | yes | Unique identifier for this checkpoint. |
|
||||
| `export` | string | yes | Shell command to export state. `{{artifacts_dir}}` is replaced with a daemon-managed path. |
|
||||
| `import` | string | no | Shell command to import state on restore. |
|
||||
|
||||
Checkpoint hooks are shell commands run by the daemon. They are best-effort — a failed export does not block snapshot, but continuity is classified as `receipt_only` instead of `checkpointed`.
|
||||
|
||||
---
|
||||
|
||||
## Continuity Policy
|
||||
|
||||
Optional pod-level configuration for compaction recovery behavior.
|
||||
|
||||
```yaml
|
||||
continuity_policy:
|
||||
enabled: true
|
||||
sync_triggers: [pre_compaction, pre_shutdown, manual, milestone]
|
||||
artifacts:
|
||||
session_log: true
|
||||
restore_brief: true
|
||||
quiz: false
|
||||
restore_protocol:
|
||||
peer_driven: true
|
||||
verify_via_quiz: false
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `enabled` | boolean | yes | — | Whether continuity is active for this pod. |
|
||||
| `sync_triggers` | string[] | no | — | When to sync. Values: `pre_compaction`, `pre_shutdown`, `manual`, `milestone`. |
|
||||
| `artifacts.session_log` | boolean | no | — | Whether to maintain a session log. |
|
||||
| `artifacts.restore_brief` | boolean | no | — | Whether to maintain a restore brief. |
|
||||
| `artifacts.quiz` | boolean | no | — | Whether to use quiz-based verification. |
|
||||
| `restore_protocol.peer_driven` | boolean | no | — | Whether peers drive the restore process. |
|
||||
| `restore_protocol.verify_via_quiz` | boolean | no | — | Whether to verify restoration via quiz. |
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
|
||||
|
||||
1. `version` and `name` are required non-empty strings.
|
||||
2. `pods` must be a non-empty array.
|
||||
3. Pod IDs must not contain dots and must be unique.
|
||||
4. Pod labels are required.
|
||||
5. Member IDs must not contain dots and must be unique within their pod.
|
||||
6. `agent_ref`, `profile`, `runtime`, and `cwd` are required for every member.
|
||||
7. Terminal nodes require the exact triple: `runtime: terminal`, `agent_ref: builtin:terminal`, `profile: none`.
|
||||
8. `agent_ref` must start with `local:` (relative) or `path:` (absolute), except `builtin:terminal`.
|
||||
9. `local:` refs must be relative paths. `path:` refs must be absolute paths.
|
||||
10. `restore_policy` must be one of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`.
|
||||
11. Pod-local edges use unqualified member IDs. Cross-pod edges use `pod.member` format.
|
||||
12. Cross-pod edges must reference different pods.
|
||||
13. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`.
|
||||
14. All file paths (`culture_file`, startup file paths, `compose_file`) must be safe relative paths.
|
||||
15. `services.kind` must be `compose`.
|
||||
16. `services.compose_file` is required when services is present.
|
||||
17. `services.project_name` must match `[a-z0-9][a-z0-9_-]*`.
|
||||
18. `services.down_policy` must be one of: `leave_running`, `down`, `down_and_volumes`.
|
||||
19. Each wait target must define exactly one of: `service`, `url`, `tcp`.
|
||||
20. `condition` is only valid on `service` targets and must be `healthy`.
|
||||
21. Startup file `delivery_hint` must be one of: `auto`, `guidance_merge`, `skill_install`, `send_text`.
|
||||
22. Startup action `type` must be one of: `slash_command`, `send_text`. (`shell` is explicitly rejected.)
|
||||
23. Startup action `phase` must be one of: `after_files`, `after_ready`.
|
||||
24. Startup action `idempotent` is a required boolean.
|
||||
25. Non-idempotent actions must not include `restore` in `applies_on`.
|
||||
26. `applies_on` values must be from: `fresh_start`, `restore`.
|
||||
|
||||
---
|
||||
|
||||
## Shipped Examples
|
||||
|
||||
These are the built-in specs shipped with OpenRig. Read them as worked examples.
|
||||
|
||||
| Spec | Location | Pods | Members | Services |
|
||||
|------|----------|------|---------|----------|
|
||||
| `product-team` | `packages/daemon/specs/rigs/product-team.yaml` | orch, dev, rev | 7 (lead, peer, impl, qa, design, r1, r2) | no |
|
||||
| `implementation-pair` | `packages/daemon/specs/rigs/implementation-pair.yaml` | dev | 2 (impl, qa) | no |
|
||||
| `adversarial-review` | `packages/daemon/specs/rigs/adversarial-review.yaml` | orch, rev | 3 (lead, r1, r2) | no |
|
||||
| `research-team` | `packages/daemon/specs/rigs/research-team.yaml` | orch, research | 3 (lead, analyst, synthesizer) | no |
|
||||
| `secrets-manager` | `packages/daemon/specs/rigs/launch/secrets-manager/rig.yaml` | vault | 1 (specialist) | yes (Vault) |
|
||||
@@ -0,0 +1,65 @@
|
||||
# Release Notes
|
||||
|
||||
This directory is the lightweight release history for OpenRig.
|
||||
|
||||
It is intentionally simpler than a monolithic `CHANGELOG.md`.
|
||||
|
||||
Each shipped release gets its own note:
|
||||
|
||||
- `v0.1.12.md`
|
||||
- `v0.1.13.md`
|
||||
- and so on
|
||||
|
||||
## Why This Exists
|
||||
|
||||
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
|
||||
|
||||
- npm package release
|
||||
- optional Git tag
|
||||
- optional GitHub Release
|
||||
- short, human-written summary of what is included
|
||||
|
||||
This keeps release notes:
|
||||
|
||||
- easy to author
|
||||
- easy to link in GitHub Releases
|
||||
- easy to paste into announcements
|
||||
- durable in the repo
|
||||
|
||||
## Minimal Process
|
||||
|
||||
For each release:
|
||||
|
||||
1. Copy `_template.md` to `vX.Y.Z.md`.
|
||||
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
|
||||
3. Create a git tag for the release:
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
4. Create a GitHub Release using the same file:
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z \
|
||||
--repo mvschwarz/openrig \
|
||||
--title "OpenRig vX.Y.Z" \
|
||||
--notes-file docs/releases/vX.Y.Z.md
|
||||
```
|
||||
|
||||
5. Publish the npm package if that is part of the release flow.
|
||||
|
||||
## Guidance
|
||||
|
||||
- Prefer user-facing language over commit-log language.
|
||||
- Group related fixes into a small number of bullets.
|
||||
- Be explicit about operator-impacting changes: setup, permissions, startup state, restore, recovery, and environment notes.
|
||||
- Keep internal refactors out unless they materially change user behavior or operator confidence.
|
||||
- If verification was limited, say so directly.
|
||||
|
||||
## Scope
|
||||
|
||||
This directory is a release-note archive, not a full historical changelog taxonomy.
|
||||
|
||||
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
|
||||
@@ -0,0 +1,38 @@
|
||||
# OpenRig vX.Y.Z
|
||||
|
||||
## Summary
|
||||
|
||||
One short paragraph describing the release at a human level.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- Change or fix cluster 1
|
||||
- Change or fix cluster 2
|
||||
- Change or fix cluster 3
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
- What users or operators will notice
|
||||
- What behavior is different now
|
||||
- Any new classifications, flows, or visible fixes
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
- Any upgrade, restart, or environment notes
|
||||
- Any migration or manual action notes
|
||||
- Any caveats for fresh installs or managed sessions
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Honest limitations that still exist after this release
|
||||
- Keep this short and specific
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- Local targeted tests
|
||||
- VM or fresh-install validation
|
||||
- Manual operator-path verification
|
||||
|
||||
## Commits Included
|
||||
|
||||
- Optional: list the main commits if useful for maintainers
|
||||
@@ -0,0 +1,58 @@
|
||||
# OpenRig v0.1.12
|
||||
|
||||
## Summary
|
||||
|
||||
This release hardens startup-state truth, improves transcript cleanup, and makes restore/recovery behavior more reliable across Claude and Codex managed sessions.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- Normalize user-clearable startup blockers so they do not masquerade as hard failures.
|
||||
- Clear stale Claude startup/error residue more aggressively.
|
||||
- Improve Codex restore continuity detection and resume metadata capture.
|
||||
- Fix transcript redraw and status-noise cleanup.
|
||||
- Harden Claude managed-session startup around MCP-related blockers.
|
||||
- Improve operational reliability around tmux health and instantiate timing.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
- Nodes blocked on interactive startup prompts are now normalized as user-clearable startup blockers instead of being left in misleading stale-failure states.
|
||||
- Claude startup state is cleaned up more aggressively when current runtime evidence proves the old failure residue is stale.
|
||||
- Codex restore behavior is more robust when resuming wrapped processes and when the TUI has scrolled or rendered extra footer/header noise.
|
||||
- Transcript cleanup is less noisy and better at removing redraw/status artifacts without leaving the terminal output in a confusing state.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
- After upgrading, restart the daemon so new startup and restore behavior is active:
|
||||
|
||||
```bash
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
- This release improves classification and recovery behavior, but it does not turn every interrupted runtime into guaranteed native continuity. Operators should still treat restore results honestly and use manual recovery paths when the product says continuity could not be proved.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Lifecycle/state handling is more truthful than before, but OpenRig still does not have a fully explicit infrastructure-grade state machine.
|
||||
- Restore remains runtime-specific and evidence-driven; some recovery paths still rely on heuristics rather than perfect provider-native primitives.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- Targeted daemon and CLI test coverage during the release workstream.
|
||||
- Focused review and validation of the transcript-cleanup change set.
|
||||
- Fresh-VM npm-install verification of the published `@openrig/cli` package, including daemon start, health route, doctor checks, `rig ps --nodes`, and UI load.
|
||||
|
||||
## Commits Included
|
||||
|
||||
- `8b07461` `cli: detect unhealthy tmux control sockets`
|
||||
- `8ca8e43` `daemon: ignore stale codex update banner once tui is live`
|
||||
- `a8adfe2` `daemon: capture codex resume ids from wrapped processes`
|
||||
- `e4619cf` `daemon: recognize resumed codex tui after header scrolls`
|
||||
- `30cf604` `daemon: accept codex footer with trailing blank lines`
|
||||
- `c44507f` `harden restore continuity and recovery guidance`
|
||||
- `105ed7e` `fix Claude MCP startup blockers for managed sessions`
|
||||
- `848b032` `cli: extend import instantiate timeout budget`
|
||||
- `43a0ace` `daemon: reject pod-aware node relaunch via legacy route`
|
||||
- `0d7e6ec` `daemon: normalize stale Claude startup state`
|
||||
- `37e2d5f` `daemon: normalize user-clearable startup blockers`
|
||||
- `74cf893` `fix: clean transcript redraw and status noise (#10)`
|
||||
@@ -0,0 +1,276 @@
|
||||
# OpenRig v0.2.0
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.2.0 is a major step forward. It ships a durable
|
||||
daemon-backed coordination layer — streams, queues, projects, views,
|
||||
watchdog supervision, and workflow runtime — together with a redesigned
|
||||
restore experience, an agent-starter registry, cross-host operations,
|
||||
and a wide set of lifecycle and recovery hardening fixes. This is the
|
||||
first public release after a long internal hardening cycle, so the
|
||||
delta from v0.1.12 is large.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- A new daemon-owned coordination layer: `rig stream`, `rig queue`,
|
||||
`rig project`, `rig view`, `rig watchdog`, and `rig workflow`.
|
||||
- An agent-starter registry that lets RigSpec members reference named
|
||||
startup-priming entries (`starter_ref`) instead of inlining them.
|
||||
- A new restore-packet command surface for capturing agent
|
||||
conversation packets across runtimes.
|
||||
- New member-level launch source modes: `session_source: { mode:
|
||||
rebuild }` and `session_source: { mode: fork }`.
|
||||
- Cross-host operations: `rig ps`, `rig whoami`, `rig send`, and
|
||||
`rig capture` accept `--host` for SSH-based shell-out.
|
||||
- Substantial lifecycle, recovery, and runtime-truth improvements
|
||||
across Claude Code and Codex managed sessions.
|
||||
- Sixteen new SQLite migrations (021 → 036) to back the new
|
||||
coordination, view, watchdog, and workflow tables, plus a few
|
||||
observability and config-profile additions.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### New coordination commands
|
||||
|
||||
A new family of `rig` commands provides a durable, daemon-backed
|
||||
coordination surface:
|
||||
|
||||
- `rig stream` — emit and query stream items; subscribe via SSE.
|
||||
- `rig queue` — create, claim, hand off, and close queue items.
|
||||
Includes `handoff`, `handoff-and-complete`, `update`, `show`,
|
||||
`list`, `whoami`, `inbox-drop`, `inbox-absorb`, `outbox-drop`,
|
||||
and `outbox-collect`.
|
||||
- `rig project` — group classified work items into durable projects
|
||||
with classifier lease/liveness/reclaim semantics.
|
||||
- `rig view` — query built-in and custom views over coordination
|
||||
state; consume change events via SSE.
|
||||
- `rig watchdog` — schedule periodic and conditional jobs with
|
||||
daemon-native supervision.
|
||||
- `rig workflow` — run multi-step workflows with durable trail
|
||||
records and a workflow-keepalive job.
|
||||
|
||||
These commands persist their state in the OpenRig daemon's SQLite
|
||||
database, are observable through the daemon API, and emit
|
||||
`view.changed` and related SSE events on update.
|
||||
|
||||
### New `starter_ref` member field
|
||||
|
||||
`rig up` now accepts `starter_ref` on RigSpec members. The daemon
|
||||
resolves the named entry from the `agent-starters/` registry at
|
||||
launch and merges its content into the seat's per-cwd guidance file
|
||||
(`CLAUDE.md` for Claude, `AGENTS.md` for Codex). Inputs containing
|
||||
credential markers are refused with a redacted refusal envelope.
|
||||
`starter_ref` is mutually exclusive with inline `session_source` on
|
||||
the same member, and is rejected upfront in plan-mode for terminal
|
||||
runtimes and fork compositions.
|
||||
|
||||
### New `session_source` modes
|
||||
|
||||
Members may now declare:
|
||||
|
||||
- `session_source: { mode: rebuild }` — rebuild a member from a
|
||||
named artifact set on every materialize.
|
||||
- `session_source: { mode: fork }` — fork a new member from an
|
||||
existing source on launch.
|
||||
|
||||
### Restore-packet command surface
|
||||
|
||||
A new `rig restore-packet` family captures restorable conversation
|
||||
packets across runtimes. It includes parsers and redaction for
|
||||
Claude and Codex transcripts, schema validation, packet writing, and
|
||||
a read/validate flow. There is also a `rig restore-check` command
|
||||
for restore-readiness checks.
|
||||
|
||||
### Cross-host operations
|
||||
|
||||
`rig ps`, `rig whoami`, `rig send`, and `rig capture` now accept
|
||||
`--host <ssh-host>` to run against a remote machine. The CLI
|
||||
shells out over SSH; no long-lived agent or daemon needs to be on
|
||||
the remote machine ahead of the call.
|
||||
|
||||
### Lifecycle, recovery, and runtime-truth improvements
|
||||
|
||||
- New lifecycle classifications: `recoverable`, `degraded`, and
|
||||
`attention_required`. Codex auth-refusal now surfaces as
|
||||
`attention_required` end-to-end.
|
||||
- `rig up <rigName>` falls back to a manual snapshot when no
|
||||
auto-pre-down snapshot exists, so launches no longer fail
|
||||
silently in that path.
|
||||
- Restore now has an explicit attempt id, and runtime-truth
|
||||
reconciliation prevents stale state from masquerading as live.
|
||||
- Codex resume now verifies the pane via a native probe; rig expand
|
||||
recovery guidance points to shrink+re-expand instead of trying to
|
||||
launch a doomed rig.
|
||||
- `rig ps --fields` now rejects unknown keys with a per-level
|
||||
allow-list. `rig ps` also supports `rigName` aliasing and a
|
||||
bounded output mode.
|
||||
|
||||
### Agents launched by the daemon now run plain `rig`
|
||||
|
||||
Codex seats launched by the daemon previously required an explicit
|
||||
`OPENRIG_HOME`/`OPENRIG_PORT` env and a non-default binary path on
|
||||
PATH. Daemon-launched seats now have plain `rig` available, so
|
||||
agents and operators can use the standard CLI without per-seat env
|
||||
gymnastics.
|
||||
|
||||
### Watchdog help, queue-actor inference, and other small fixes
|
||||
|
||||
- `rig watchdog --help` now lists the workflow-keepalive job kind.
|
||||
- `rig queue` infers the actor from the managed session when no
|
||||
explicit actor is provided.
|
||||
- The CLI bin keeps its executable bit across builds.
|
||||
- Chatroom wait test fixture is time-stable (no more flaky
|
||||
`chatroom wait` test under sanitized environments).
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/[email protected]
|
||||
rig --version # expect 0.2.0
|
||||
```
|
||||
|
||||
You will need to restart the daemon to pick up the new schema and
|
||||
behavior:
|
||||
|
||||
```bash
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
The daemon will apply migrations 021-036 to its SQLite database on
|
||||
first startup. Migrations are forward-only by convention; if you
|
||||
need to roll back, restore the daemon DB from a snapshot and switch
|
||||
the daemon binary back to the previous build before restart. Take a
|
||||
DB snapshot before the first post-upgrade daemon start if you want a
|
||||
clean rollback path:
|
||||
|
||||
```bash
|
||||
cp ~/.openrig/openrig.sqlite ~/.openrig/openrig.sqlite.pre-v0.2.0.bak
|
||||
```
|
||||
|
||||
If you maintain a managed control-plane environment with live rigs
|
||||
mid-flight, plan the daemon restart as an upgrade window. The
|
||||
upgrade is straightforward, but daemon stop/start interrupts active
|
||||
SSE consumers and any in-progress `rig up` flow.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- The new coordination surfaces (`rig stream`, `rig queue`,
|
||||
`rig project`, `rig view`, `rig watchdog`, `rig workflow`) are
|
||||
daemon-backed. The host daemon must be on v0.2.0 to serve them; an
|
||||
older daemon will not expose these commands and may silently
|
||||
ignore opt-in fields like `starter_ref` on members.
|
||||
- `runtime: claude` is not a valid RigSpec runtime spelling; use
|
||||
`runtime: claude-code` for Claude managed seats. This is harness/
|
||||
spec syntax, not a product limitation, but it has surprised
|
||||
operators.
|
||||
- Watchdog policies and workflow specs are persisted in the daemon
|
||||
DB. There is currently no first-class CLI for editing custom
|
||||
watchdog policies in bulk; expect more ergonomics in a future
|
||||
release.
|
||||
- The Phase E surfaces (rails, rendering, steering for the
|
||||
coordination layer) are intentionally deferred to future product
|
||||
lanes (PL-006/PL-007/PL-008) and are not part of v0.2.0.
|
||||
- Lifecycle classification is more truthful than before, but
|
||||
OpenRig still does not have a fully explicit infrastructure-grade
|
||||
state machine. Some recovery paths still rely on heuristics
|
||||
rather than perfect provider-native primitives.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- Sanitized full CLI suite under Node 22 with OpenRig env stripped:
|
||||
56 files / 794 tests passing.
|
||||
- Root `npm run build` and `npm run build:package` passing.
|
||||
- Real-agent dogfood under Mode 3 isolated VM with VM-local OpenRig
|
||||
daemon/state and real OpenRig-managed Claude Code and Codex seats:
|
||||
Claude-only, Codex-only, and mixed Claude→Codex paths all
|
||||
exercised end-to-end through the new `rig queue`, `rig stream`,
|
||||
`rig workflow`, and `rig watchdog` surfaces.
|
||||
- VM-local global install of `@openrig/cli` from source, with
|
||||
`rig --version` reporting the expected version and managed seats
|
||||
running plain `rig`.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full delta from `v0.1.12` to `e40214f` is 117 commits, 277
|
||||
files changed, 49,209 insertions and 683 deletions. The list below
|
||||
groups the load-bearing commits by theme:
|
||||
|
||||
### Coordination primitive system
|
||||
|
||||
- `d8d8ee9 feat(daemon,cli): coordination primitive system — stream/queue/inbox/outbox + hot-potato strict-rejection`
|
||||
- `fd4fb30 fix(daemon,cli): topology-backed validation + default nudge + handoff-and-complete + whoami + SSE alias + CLI tests`
|
||||
- `194a1eb fix(daemon): SSE route-order shadowing fix`
|
||||
- `4ac0d20 feat(daemon,cli): L2 Project (Classifier) + L5 View`
|
||||
- `9f86df4 fix(daemon,cli): L1→L2 FK + view.changed event bridge`
|
||||
- `629d061 fix(daemon): queue.updated event + bridge mapping for general state mutations`
|
||||
- `48c0267 feat(daemon,cli): daemon-native Watchdog supervision tree`
|
||||
- `dd9820e fix(daemon): watchdog policy POC contract parity + active-wake throttle`
|
||||
- `1a3b313 fix(daemon): yaml-package frontmatter parser + scan_interval scheduler test`
|
||||
- `55bdcbb feat(daemon,cli): daemon-native Workflow Runtime + workflow-keepalive`
|
||||
- `bf28cc2 fix(daemon): workflow projection routes through queue closure primitive + waiting frontier preservation + failed status`
|
||||
- `1c94719 fix(daemon): durable current-step binding + allowed_exits enforcement at projection`
|
||||
- `3e32b0a fix(daemon): map exit_not_allowed to HTTP 400 + route regression`
|
||||
- `05ca19c fix(daemon): workflow_step_trails listForInstance ordered by rowid (deterministic insertion order)`
|
||||
- `e40214f fix: include workflow keepalive in watchdog help`
|
||||
|
||||
### Restore-Packet surface
|
||||
|
||||
- `de2dbd4 feat(cli): restore-packet schema + validator + CLI shell + wiring`
|
||||
- `40d0724 fix(cli): restore-packet schema packaging fix`
|
||||
- `bdf4cf6 feat(cli): restore-packet runtime parsers + runtime-detect + redaction + omitted-records`
|
||||
- `5c87145 fix(cli): Claude parser nested tool_use/tool_result handling`
|
||||
- `e5de3ab feat(cli): packet-writer atomic emission + write action impl`
|
||||
- `a8401f6 fix(cli): --source-jsonl provenance from parsed session_meta`
|
||||
- `c7b74fa feat(daemon,cli): full-read transcript route + final M2 regression`
|
||||
- `a5132da feat(cli): rig restore-packet read + validate actual implementations`
|
||||
- `ab822ee test(cli): command-registration meta-tests + final regression check`
|
||||
|
||||
### Agent Starter v1 vertical
|
||||
|
||||
- `eb4acf2 feat(daemon): Agent Starter v1 schema + resolver scaffolding`
|
||||
- `2673e30 fix(daemon): codec emits starter_ref; expansion preserves it; resolver redacts match-line`
|
||||
- `2f8f269 feat(daemon): Agent Starter v1 adapter pass-through + rig up integration`
|
||||
- `6af2754 test(daemon, cli): Agent Starter v1 real-adapter, route end-to-end, CLI plan content, continuityOutcome + SQLite roundtrip`
|
||||
|
||||
### session_source rebuild and fork modes
|
||||
|
||||
- `c7b6df1 feat(daemon): session_source: { mode: fork } member-level launch input`
|
||||
- `578bd5c feat(daemon): session_source: { mode: rebuild }`
|
||||
|
||||
### Cross-host operations
|
||||
|
||||
- `cdce3a6 feat(cli): cross-host rig send/capture via --host (CLI-side ssh shell-out)`
|
||||
- `6b7043a feat(cli): rig ps and rig whoami accept --host`
|
||||
|
||||
### Lifecycle, recovery, and runtime-truth
|
||||
|
||||
- `3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile`
|
||||
- `fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection`
|
||||
- `66785c8 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation`
|
||||
- `e70694f fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists`
|
||||
- `0a9fb43 feat(daemon,cli): add rigName alias, lifecycleState column, and bounded ps output options`
|
||||
- `4e277d7 docs(cli-reference): bring rig ps section in line with shipped 0a9fb43`
|
||||
- `315745a feat(cli): rig ps --fields rejects unknown keys with per-level allow-list`
|
||||
- `ab01331 feat(daemon): codex resume verifies pane via native probe`
|
||||
- `fcfc7dc fix(cli): rig expand recovery guidance points to shrink+re-expand, not doomed rig launch`
|
||||
- `63ee206 feat(daemon): codex auth-refusal surfaces as attention_required end-to-end`
|
||||
- `53e9409 fix(daemon): codex auth-refusal surfaces as attention_required across pod-aware launch path`
|
||||
|
||||
### Final fixes during release loop
|
||||
|
||||
- `934f3c9 fix: launch codex seats with full access posture`
|
||||
- `fe5b23e fix: propagate daemon env to launched seats`
|
||||
- `cff08c7 test: make chatroom wait fixture time-stable`
|
||||
- `a536df3 fix: preserve cli bin executable bit on build`
|
||||
- `90b1772 fix: infer queue actor from managed session`
|
||||
|
||||
### Recovery markers
|
||||
|
||||
- `7a0ae0f chore: mark PL-004 Phase D checkout recovery`
|
||||
|
||||
The remaining ~50 commits are smaller fixes, test additions, and
|
||||
docs updates that compose into the themed groups above. See `git
|
||||
log v0.1.12..v0.2.0` for the full list.
|
||||
Generated
+25
-6
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
"packages/ui",
|
||||
"packages/cli"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -4592,6 +4593,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -7379,14 +7386,18 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"commander": "^13.0.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^2.3.0",
|
||||
"yaml": "^2.8.3",
|
||||
@@ -7399,11 +7410,15 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
@@ -7419,11 +7434,15 @@
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
|
||||
+6
-3
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -10,11 +11,13 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"test": "npm run test --workspaces",
|
||||
"test": "npm run test:repo && npm run test:workspaces",
|
||||
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs",
|
||||
"test:workspaces": "npm run test --workspaces",
|
||||
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
|
||||
"build:package": "bash scripts/build-package.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
@@ -31,10 +31,12 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"daemon",
|
||||
"ui"
|
||||
"ui",
|
||||
"scripts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
|
||||
"postinstall": "node scripts/check-abi.mjs",
|
||||
"prepublishOnly": "bash ../../scripts/build-package.sh",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
@@ -42,9 +44,12 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"commander": "^13.0.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^2.3.0",
|
||||
"yaml": "^2.8.3",
|
||||
@@ -54,5 +59,8 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Postinstall ABI sanity check for @openrig/cli.
|
||||
// Fails loudly if better-sqlite3 native binary does not match the active
|
||||
// Node runtime. Layered on top of the engines.node constraint in
|
||||
// package.json — engines warns at npm level; this hard-blocks at install
|
||||
// time with a copy-paste fix.
|
||||
|
||||
/**
|
||||
* @param {{ nodeVersion: string, loadNativeAddon: () => void }} deps
|
||||
* @returns {{ ok: true } | { ok: false, message: string }}
|
||||
*/
|
||||
export function checkAbi({ nodeVersion, loadNativeAddon }) {
|
||||
// Phase 1: version-range check (fast path).
|
||||
// Even-numbered Node majors (20, 22, 24) are LTS lines with native addon
|
||||
// prebuilds. Odd-numbered majors (21, 23, 25) generally lack them.
|
||||
const match = nodeVersion.match(/^v?(\d+)/);
|
||||
const major = match ? parseInt(match[1], 10) : 0;
|
||||
|
||||
if (major < 20) {
|
||||
return {
|
||||
ok: false,
|
||||
message: [
|
||||
"",
|
||||
" ╔══════════════════════════════════════════════════════════════╗",
|
||||
` ║ @openrig/cli requires Node.js 20, 22, or 24 (LTS). ║`,
|
||||
` ║ Current: ${nodeVersion.padEnd(49)}║`,
|
||||
" ║ ║",
|
||||
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
|
||||
" ╚══════════════════════════════════════════════════════════════╝",
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
if (major % 2 !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: [
|
||||
"",
|
||||
" ╔══════════════════════════════════════════════════════════════╗",
|
||||
` ║ @openrig/cli does not support odd-numbered Node releases. ║`,
|
||||
` ║ Current: ${nodeVersion.padEnd(49)}║`,
|
||||
" ║ ║",
|
||||
" ║ Odd Node versions (21, 23, 25, …) lack native addon ║",
|
||||
" ║ prebuilds for better-sqlite3. The daemon will fail. ║",
|
||||
" ║ ║",
|
||||
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
|
||||
" ╚══════════════════════════════════════════════════════════════╝",
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: ABI load check (catches edge cases like local Node builds
|
||||
// or prebuild packaging gaps on supported versions).
|
||||
try {
|
||||
loadNativeAddon();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
const isAbiMismatch = detail.includes("NODE_MODULE_VERSION");
|
||||
|
||||
if (isAbiMismatch) {
|
||||
return {
|
||||
ok: false,
|
||||
message: [
|
||||
"",
|
||||
" ╔══════════════════════════════════════════════════════════════╗",
|
||||
" ║ better-sqlite3 native binary does not match this Node. ║",
|
||||
` ║ Current: ${nodeVersion.padEnd(49)}║`,
|
||||
" ║ ║",
|
||||
" ║ Fix: npm rebuild better-sqlite3 ║",
|
||||
" ║ or: nvm install 22 && npm install -g @openrig/cli ║",
|
||||
" ╚══════════════════════════════════════════════════════════════╝",
|
||||
"",
|
||||
` Detail: ${detail}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: [
|
||||
"",
|
||||
" ╔══════════════════════════════════════════════════════════════╗",
|
||||
" ║ better-sqlite3 native addon failed to load. ║",
|
||||
` ║ Current: ${nodeVersion.padEnd(49)}║`,
|
||||
" ║ ║",
|
||||
" ║ Fix: npm rebuild better-sqlite3 ║",
|
||||
" ║ If that fails, reinstall with a supported Node version: ║",
|
||||
" ║ nvm install 22 && npm install -g @openrig/cli ║",
|
||||
" ╚══════════════════════════════════════════════════════════════╝",
|
||||
"",
|
||||
` Detail: ${detail}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Run when executed as postinstall script ---
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
(import.meta.url === `file://${process.argv[1]}` ||
|
||||
process.argv[1].endsWith("check-abi.mjs"));
|
||||
|
||||
if (isMain) {
|
||||
const { createRequire } = await import("node:module");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const result = checkAbi({
|
||||
nodeVersion: process.version,
|
||||
loadNativeAddon: () => require("better-sqlite3"),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(result.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
|
||||
|
||||
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
|
||||
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
|
||||
const packageRoot = path.resolve(path.dirname(wrapperPath), "..");
|
||||
@@ -19,6 +22,51 @@ export function isDirectRun(argv1 = process.argv[1], moduleUrl = import.meta.url
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNodeReexecBinary(
|
||||
currentExecPath = process.execPath,
|
||||
invokedPath = process.argv[1],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
exists: (candidate: string) => boolean = existsSync,
|
||||
realpath: (candidate: string) => string = realpathSync,
|
||||
): string | null {
|
||||
if (!invokedPath) return null;
|
||||
if (env[REEXEC_GUARD_ENV] === "1") return null;
|
||||
|
||||
try {
|
||||
const binDir = realpath(path.dirname(invokedPath));
|
||||
const siblingNode = path.join(binDir, process.platform === "win32" ? "node.exe" : "node");
|
||||
if (!exists(siblingNode)) return null;
|
||||
|
||||
const normalizedSiblingNode = realpath(siblingNode);
|
||||
const normalizedCurrentExec = realpath(currentExecPath);
|
||||
if (normalizedSiblingNode === normalizedCurrentExec) return null;
|
||||
|
||||
return normalizedSiblingNode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeReexecWithSiblingNode(argv = process.argv): void {
|
||||
const preferredNode = resolveNodeReexecBinary(process.execPath, argv[1], process.env);
|
||||
if (!preferredNode) return;
|
||||
|
||||
const scriptPath = argv[1] ? realpathSync(argv[1]) : fileURLToPath(import.meta.url);
|
||||
const result = spawnSync(preferredNode, [scriptPath, ...argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
[REEXEC_GUARD_ENV]: "1",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
export async function run(argv = process.argv): Promise<void> {
|
||||
const normalizedArgv = [...argv];
|
||||
if (normalizedArgv[1]) {
|
||||
@@ -31,5 +79,6 @@ export async function run(argv = process.argv): Promise<void> {
|
||||
}
|
||||
|
||||
if (isDirectRun()) {
|
||||
maybeReexecWithSiblingNode(process.argv);
|
||||
await run(process.argv);
|
||||
}
|
||||
+24
-19
@@ -14,6 +14,10 @@ export interface DaemonResponse<T = unknown> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface DaemonClientOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
@@ -42,51 +46,52 @@ export class DaemonClient {
|
||||
this.timeoutMs = options?.timeoutMs ?? 5_000;
|
||||
}
|
||||
|
||||
async get<T = unknown>(path: string): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "GET" });
|
||||
async get<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "GET" }, options);
|
||||
}
|
||||
|
||||
async getText(path: string): Promise<DaemonResponse<string>> {
|
||||
return this.requestText(path, { method: "GET" });
|
||||
async getText(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
return this.requestText(path, { method: "GET" }, options);
|
||||
}
|
||||
|
||||
async post<T = unknown>(path: string, body?: unknown): Promise<DaemonResponse<T>> {
|
||||
async post<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>): Promise<DaemonResponse<T>> {
|
||||
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": contentType, ...extraHeaders },
|
||||
body: text,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async postExpectText(path: string, body?: unknown): Promise<DaemonResponse<string>> {
|
||||
async postExpectText(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
return this.requestText(path, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async delete<T = unknown>(path: string): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" });
|
||||
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" }, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit): Promise<Response> {
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
||||
try {
|
||||
return await fetchWithTimeout(
|
||||
this.fetchImpl,
|
||||
`${this.baseUrl}${path}`,
|
||||
init,
|
||||
{
|
||||
timeoutMs: this.timeoutMs,
|
||||
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${this.timeoutMs}ms`,
|
||||
timeoutMs,
|
||||
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -95,14 +100,14 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, init: RequestInit): Promise<DaemonResponse<T>> {
|
||||
const res = await this.fetch(path, init);
|
||||
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const res = await this.fetch(path, init, options);
|
||||
const data = (await res.json()) as T;
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
private async requestText(path: string, init: RequestInit): Promise<DaemonResponse<string>> {
|
||||
const res = await this.fetch(path, init);
|
||||
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
const res = await this.fetch(path, init, options);
|
||||
const data = await res.text();
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import path from "node:path";
|
||||
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
|
||||
|
||||
export const CMUX_SETTINGS_DISCLOSURE_PATH = "~/.config/cmux/settings.json";
|
||||
export const CMUX_DEFAULT_SOCKET_CONTROL_MODE = "cmuxOnly";
|
||||
|
||||
const CMUX_INCOMPATIBLE_MODES = new Set(["off", "cmuxOnly"]);
|
||||
|
||||
export interface CmuxSettingsReadResult {
|
||||
mode: string;
|
||||
source: "settings" | "default";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function resolveCmuxSettingsPath(homeDir = process.env["HOME"] ?? "~"): string {
|
||||
return path.join(homeDir, ".config", "cmux", "settings.json");
|
||||
}
|
||||
|
||||
export function isCmuxSocketControlCompatible(mode: string | null): boolean {
|
||||
return typeof mode === "string" && !CMUX_INCOMPATIBLE_MODES.has(mode);
|
||||
}
|
||||
|
||||
export function readCmuxSocketControlModeFromText(text: string | null): CmuxSettingsReadResult {
|
||||
if (!text || !text.trim()) {
|
||||
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
|
||||
}
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
|
||||
source: "settings",
|
||||
error: formatParseErrors(errors),
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
|
||||
source: "settings",
|
||||
error: "cmux settings must contain a JSON object.",
|
||||
};
|
||||
}
|
||||
|
||||
const automation = parsed["automation"];
|
||||
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
|
||||
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
|
||||
}
|
||||
|
||||
const mode = automation["socketControlMode"];
|
||||
if (typeof mode === "string" && mode.trim()) {
|
||||
return { mode, source: "settings" };
|
||||
}
|
||||
|
||||
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
|
||||
}
|
||||
|
||||
export function upsertCmuxSocketControlMode(text: string | null, mode: string): { content: string; changed: boolean } {
|
||||
const source = text && text.trim() ? text : "{\n}\n";
|
||||
const edits = modify(source, ["automation", "socketControlMode"], mode, {
|
||||
formattingOptions: {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
eol: "\n",
|
||||
},
|
||||
});
|
||||
|
||||
if (edits.length === 0) {
|
||||
return { content: ensureTrailingNewline(source), changed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
content: ensureTrailingNewline(applyEdits(source, edits)),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(text: string): string {
|
||||
return text.endsWith("\n") ? text : `${text}\n`;
|
||||
}
|
||||
|
||||
function formatParseErrors(errors: ParseError[]): string {
|
||||
return errors
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join("; ");
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS = 120_000;
|
||||
|
||||
function logStageDetailErrors(data: Record<string, unknown>) {
|
||||
const stages = (data["stages"] as Array<{ stage: string; status: string; detail?: unknown }>) ?? [];
|
||||
for (const stage of stages) {
|
||||
@@ -107,7 +109,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
|
||||
sourceRef,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
|
||||
autoApprove: opts.yes ?? false,
|
||||
});
|
||||
}, { timeoutMs: LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
|
||||
@@ -3,10 +3,22 @@ import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
|
||||
export function captureCommand(depsOverride?: StatusDeps): Command {
|
||||
export interface CaptureDeps extends StatusDeps {
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
crossHostRun?: (
|
||||
host: Parameters<typeof runCrossHostCommand>[0],
|
||||
argv: readonly string[],
|
||||
opts?: RunCrossHostCommandOpts,
|
||||
) => ReturnType<typeof runCrossHostCommand>;
|
||||
}
|
||||
|
||||
export function captureCommand(depsOverride?: CaptureDeps): Command {
|
||||
const cmd = new Command("capture").description("Capture terminal output from agent sessions");
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
const getDeps = (): CaptureDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
@@ -16,6 +28,7 @@ export function captureCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--rig <name>", "Capture all sessions in a rig")
|
||||
.option("--pod <name>", "Capture all sessions in a pod")
|
||||
.option("--lines <n>", "Number of lines to capture (default: 20)", "20")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -24,12 +37,22 @@ Examples:
|
||||
rig capture --rig my-rig
|
||||
rig capture --pod dev --rig my-rig
|
||||
rig capture --rig my-rig --json
|
||||
rig capture --host vm-claude-test dev-impl@my-rig --lines 50
|
||||
|
||||
Supported notes:
|
||||
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
|
||||
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.`)
|
||||
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; json?: boolean }) => {
|
||||
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.
|
||||
- --host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
|
||||
via single-hop ssh. The remote rig is authoritative on what it can capture.`)
|
||||
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; host?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
if (opts.host) {
|
||||
await runCrossHostCapture(opts.host, session, opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
@@ -84,3 +107,53 @@ Supported notes:
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function runCrossHostCapture(
|
||||
hostId: string,
|
||||
session: string | undefined,
|
||||
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
|
||||
deps: CaptureDeps,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const runner = deps.crossHostRun ?? runCrossHostCommand;
|
||||
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// Reconstruct argv. Order: positional first, then flags.
|
||||
const argv: string[] = ["rig", "capture"];
|
||||
if (session) argv.push(session);
|
||||
if (opts.rig) argv.push("--rig", opts.rig);
|
||||
if (opts.pod) argv.push("--pod", opts.pod);
|
||||
if (opts.lines !== undefined) argv.push("--lines", opts.lines);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: host.target },
|
||||
result,
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, opts.json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface CompactPlanDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
interface RigEntry {
|
||||
rigId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface NodeEntry {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
logicalId: string;
|
||||
canonicalSessionName: string | null;
|
||||
runtime: string | null;
|
||||
sessionStatus: string | null;
|
||||
startupStatus: string | null;
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
contextUsage?: {
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
source: string | null;
|
||||
availability: string | null;
|
||||
sampledAt: string | null;
|
||||
fresh: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface BasePlanEntry {
|
||||
session: string | null;
|
||||
rig: string;
|
||||
logicalId: string;
|
||||
runtime: string;
|
||||
usedPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
estimatedUsedTokens: number | null;
|
||||
contextFreshness: "fresh" | "stale" | "unknown";
|
||||
sessionStatus: string | null;
|
||||
startupStatus: string | null;
|
||||
}
|
||||
|
||||
interface CompactPlanThresholds {
|
||||
thresholdTokens: number;
|
||||
thresholdPercent: number;
|
||||
}
|
||||
|
||||
interface CompactPlanSeatPolicy {
|
||||
oneSeatAtATime: true;
|
||||
autoCompactAllowed: false;
|
||||
explicitAuthorizationRequired: true;
|
||||
}
|
||||
|
||||
interface NotificationPacket {
|
||||
recipient: string | null;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface CandidateEntry extends BasePlanEntry {
|
||||
status: "candidate_with_caveats";
|
||||
thresholdReason: string;
|
||||
reasons: string[];
|
||||
missingEvidence: string[];
|
||||
precompactRequirements: string[];
|
||||
seatPolicy: CompactPlanSeatPolicy;
|
||||
notificationPacket: NotificationPacket;
|
||||
nextAction: string;
|
||||
}
|
||||
|
||||
interface BlockedEntry extends BasePlanEntry {
|
||||
status: "blocked";
|
||||
thresholdReason: string | null;
|
||||
reasons: string[];
|
||||
missingEvidence: string[];
|
||||
precompactRequirements: string[];
|
||||
seatPolicy: CompactPlanSeatPolicy;
|
||||
notificationPacket: NotificationPacket;
|
||||
nextAction: string;
|
||||
}
|
||||
|
||||
interface SkippedEntry extends BasePlanEntry {
|
||||
status: "skipped";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface CompactPlanResult {
|
||||
summary: {
|
||||
totalSeats: number;
|
||||
claudeSeats: number;
|
||||
candidateCount: number;
|
||||
blockedCount: number;
|
||||
skippedCount: number;
|
||||
requiresAuthorization: true;
|
||||
};
|
||||
policy: {
|
||||
mode: "read_only_plan";
|
||||
defaultThresholdTokens: number;
|
||||
defaultThresholdPercent: number;
|
||||
thresholdTokens: number;
|
||||
thresholdPercent: number;
|
||||
oneSeatAtATime: true;
|
||||
autoCompactAllowed: false;
|
||||
explicitAuthorizationRequired: true;
|
||||
};
|
||||
recommendedOrder: string[];
|
||||
candidates: CandidateEntry[];
|
||||
blocked: BlockedEntry[];
|
||||
skipped: SkippedEntry[];
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD_TOKENS = 400_000;
|
||||
const PERCENT_FALLBACK_THRESHOLD = 80;
|
||||
const FRESHNESS_THRESHOLD_S = 600;
|
||||
const PRECOMPACT_REQUIREMENTS = [
|
||||
"fresh_context_sample",
|
||||
"checkpoint_or_restore_packet_verification",
|
||||
"explicit_operator_authorization",
|
||||
"one_seat_at_a_time_only",
|
||||
"post_compaction_restore_audit",
|
||||
];
|
||||
const SEAT_POLICY: CompactPlanSeatPolicy = {
|
||||
oneSeatAtATime: true,
|
||||
autoCompactAllowed: false,
|
||||
explicitAuthorizationRequired: true,
|
||||
};
|
||||
|
||||
function defaultThresholds(): CompactPlanThresholds {
|
||||
return {
|
||||
thresholdTokens: DEFAULT_THRESHOLD_TOKENS,
|
||||
thresholdPercent: PERCENT_FALLBACK_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateUsedTokens(usedPercentage: number | null, contextWindowSize: number | null): number | null {
|
||||
if (usedPercentage == null || contextWindowSize == null) return null;
|
||||
return Math.round(contextWindowSize * usedPercentage / 100);
|
||||
}
|
||||
|
||||
function computeFreshness(contextUsage: NodeEntry["contextUsage"]): "fresh" | "stale" | "unknown" {
|
||||
if (!contextUsage || contextUsage.usedPercentage == null) return "unknown";
|
||||
if (contextUsage.sampledAt) {
|
||||
const ageSeconds = (Date.now() - new Date(contextUsage.sampledAt).getTime()) / 1000;
|
||||
return ageSeconds <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
|
||||
}
|
||||
if (contextUsage.fresh) return "fresh";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function baseEntry(node: NodeEntry): BasePlanEntry {
|
||||
const usedPercentage = node.contextUsage?.usedPercentage ?? null;
|
||||
const contextWindowSize = node.contextUsage?.contextWindowSize ?? null;
|
||||
return {
|
||||
session: node.canonicalSessionName,
|
||||
rig: node.rigName,
|
||||
logicalId: node.logicalId,
|
||||
runtime: node.runtime ?? "unknown",
|
||||
usedPercentage,
|
||||
contextWindowSize,
|
||||
estimatedUsedTokens: estimateUsedTokens(usedPercentage, contextWindowSize),
|
||||
contextFreshness: computeFreshness(node.contextUsage),
|
||||
sessionStatus: node.sessionStatus,
|
||||
startupStatus: node.startupStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function isClaude(node: NodeEntry): boolean {
|
||||
return node.runtime === "claude-code";
|
||||
}
|
||||
|
||||
function isSeatRunningReady(node: NodeEntry): boolean {
|
||||
return node.sessionStatus === "running" && node.startupStatus === "ready";
|
||||
}
|
||||
|
||||
function candidateSortScore(candidate: CandidateEntry): number {
|
||||
return candidate.estimatedUsedTokens ?? ((candidate.usedPercentage ?? 0) * 1_000);
|
||||
}
|
||||
|
||||
function notificationPacket(node: NodeEntry, status: "candidate_with_caveats" | "blocked", reasons: string[], missingEvidence: string[]): NotificationPacket {
|
||||
const recipient = node.canonicalSessionName;
|
||||
const subject = status === "candidate_with_caveats"
|
||||
? `Compact-plan marshal check for ${node.logicalId}`
|
||||
: `Compact-plan evidence needed for ${node.logicalId}`;
|
||||
const session = recipient ?? node.logicalId;
|
||||
const text = status === "candidate_with_caveats"
|
||||
? [
|
||||
`Read-only compact-plan flagged ${session} for one-seat-at-a-time Claude continuity triage.`,
|
||||
`Reasons: ${reasons.join(", ")}.`,
|
||||
`Before any compaction: verify checkpoint/restore evidence, get explicit operator authorization, compact only this seat, and audit restore after compact-in-place.`,
|
||||
`No automatic compaction has been run.`,
|
||||
].join(" ")
|
||||
: [
|
||||
`Read-only compact-plan cannot safely plan ${session} yet.`,
|
||||
`Blockers: ${reasons.join(", ")}.`,
|
||||
`Missing evidence: ${missingEvidence.join(", ") || "none"}.`,
|
||||
`Resolve these before requesting one-seat-at-a-time compaction authorization.`,
|
||||
].join(" ");
|
||||
return { recipient, subject, text };
|
||||
}
|
||||
|
||||
function thresholdReason(base: BasePlanEntry, thresholds: CompactPlanThresholds): string | null {
|
||||
if (base.estimatedUsedTokens != null && base.estimatedUsedTokens >= thresholds.thresholdTokens) {
|
||||
return "above_token_threshold";
|
||||
}
|
||||
if (base.estimatedUsedTokens == null && (base.usedPercentage ?? 0) >= thresholds.thresholdPercent) {
|
||||
return "above_percent_threshold_missing_window";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): CandidateEntry | BlockedEntry | SkippedEntry {
|
||||
const base = baseEntry(node);
|
||||
|
||||
if (!isClaude(node)) {
|
||||
return {
|
||||
...base,
|
||||
status: "skipped",
|
||||
reason: node.runtime === "codex" ? "codex_not_managed_by_claude_compact_in_place" : "non_claude_runtime",
|
||||
};
|
||||
}
|
||||
|
||||
const blockedReasons: string[] = [];
|
||||
const blockedMissingEvidence: string[] = [];
|
||||
if (!node.canonicalSessionName) {
|
||||
blockedReasons.push("missing_canonical_session");
|
||||
blockedMissingEvidence.push("canonical_session_name");
|
||||
}
|
||||
if (!isSeatRunningReady(node)) {
|
||||
blockedReasons.push("seat_not_running_or_ready");
|
||||
}
|
||||
if (base.usedPercentage == null) {
|
||||
blockedReasons.push("context_unknown");
|
||||
blockedMissingEvidence.push("context_usage");
|
||||
}
|
||||
|
||||
if (blockedReasons.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
status: "blocked",
|
||||
thresholdReason: thresholdReason(base, thresholds),
|
||||
reasons: blockedReasons,
|
||||
missingEvidence: blockedMissingEvidence,
|
||||
precompactRequirements: PRECOMPACT_REQUIREMENTS,
|
||||
seatPolicy: SEAT_POLICY,
|
||||
notificationPacket: notificationPacket(node, "blocked", blockedReasons, blockedMissingEvidence),
|
||||
nextAction: "Resolve blocked seat state and collect fresh context before adding this seat to marshal triage.",
|
||||
};
|
||||
}
|
||||
|
||||
const reason = thresholdReason(base, thresholds);
|
||||
if (!reason) {
|
||||
return {
|
||||
...base,
|
||||
status: "skipped",
|
||||
reason: "below_threshold",
|
||||
};
|
||||
}
|
||||
|
||||
const reasons = [
|
||||
reason,
|
||||
"authorization_required",
|
||||
];
|
||||
const missingEvidence = ["checkpoint_or_restore_packet_verification"];
|
||||
|
||||
if (base.contextWindowSize == null) {
|
||||
missingEvidence.push("context_window_size");
|
||||
}
|
||||
if (base.contextFreshness === "stale") {
|
||||
reasons.push("context_stale");
|
||||
missingEvidence.push("fresh_context_sample");
|
||||
} else if (base.contextFreshness === "unknown") {
|
||||
reasons.push("context_freshness_unknown");
|
||||
missingEvidence.push("fresh_context_sample");
|
||||
}
|
||||
if (!node.tmuxAttachCommand && !node.resumeCommand) {
|
||||
missingEvidence.push("attach_or_resume_evidence");
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: "candidate_with_caveats",
|
||||
thresholdReason: reason,
|
||||
reasons,
|
||||
missingEvidence,
|
||||
precompactRequirements: PRECOMPACT_REQUIREMENTS,
|
||||
seatPolicy: SEAT_POLICY,
|
||||
notificationPacket: notificationPacket(node, "candidate_with_caveats", reasons, missingEvidence),
|
||||
nextAction: "Verify checkpoint/restore evidence, get explicit authorization, compact one Claude seat, then audit restore using claude-compact-in-place.",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): CompactPlanResult {
|
||||
const candidates: CandidateEntry[] = [];
|
||||
const blocked: BlockedEntry[] = [];
|
||||
const skipped: SkippedEntry[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const entry = analyzeNode(node, thresholds);
|
||||
if (entry.status === "candidate_with_caveats") candidates.push(entry);
|
||||
else if (entry.status === "blocked") blocked.push(entry);
|
||||
else skipped.push(entry);
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const score = candidateSortScore(b) - candidateSortScore(a);
|
||||
if (score !== 0) return score;
|
||||
return (a.session ?? a.logicalId).localeCompare(b.session ?? b.logicalId);
|
||||
});
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalSeats: nodes.length,
|
||||
claudeSeats: nodes.filter(isClaude).length,
|
||||
candidateCount: candidates.length,
|
||||
blockedCount: blocked.length,
|
||||
skippedCount: skipped.length,
|
||||
requiresAuthorization: true,
|
||||
},
|
||||
policy: {
|
||||
mode: "read_only_plan",
|
||||
defaultThresholdTokens: DEFAULT_THRESHOLD_TOKENS,
|
||||
defaultThresholdPercent: PERCENT_FALLBACK_THRESHOLD,
|
||||
thresholdTokens: thresholds.thresholdTokens,
|
||||
thresholdPercent: thresholds.thresholdPercent,
|
||||
oneSeatAtATime: true,
|
||||
autoCompactAllowed: false,
|
||||
explicitAuthorizationRequired: true,
|
||||
},
|
||||
recommendedOrder: candidates
|
||||
.map((candidate) => candidate.session)
|
||||
.filter((session): session is string => Boolean(session)),
|
||||
candidates,
|
||||
blocked,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function printHuman(plan: CompactPlanResult): void {
|
||||
console.log("READ-ONLY PLAN - does not compact");
|
||||
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
|
||||
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
|
||||
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
|
||||
console.log();
|
||||
|
||||
if (plan.recommendedOrder.length > 0) {
|
||||
console.log("One-seat-at-a-time recommended marshal triage order:");
|
||||
for (const [index, session] of plan.recommendedOrder.entries()) {
|
||||
console.log(` ${index + 1}. ${session}`);
|
||||
}
|
||||
} else {
|
||||
console.log("One-seat-at-a-time recommended marshal triage order: none");
|
||||
}
|
||||
|
||||
if (plan.candidates.length > 0) {
|
||||
console.log();
|
||||
console.log("Candidates with caveats:");
|
||||
for (const candidate of plan.candidates) {
|
||||
const estimate = candidate.estimatedUsedTokens == null ? "unknown tokens" : `${candidate.estimatedUsedTokens} estimated tokens`;
|
||||
console.log(` - ${candidate.session}: ${estimate}; threshold=${candidate.thresholdReason}; reasons=${candidate.reasons.join(", ")}; missing=${candidate.missingEvidence.join(", ")}`);
|
||||
console.log(` Notify: ${candidate.notificationPacket.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.blocked.length > 0) {
|
||||
console.log();
|
||||
console.log("Blocked / not safely plannable:");
|
||||
for (const blocked of plan.blocked) {
|
||||
console.log(` - ${blocked.session ?? blocked.logicalId}: reasons=${blocked.reasons.join(", ")}; missing=${blocked.missingEvidence.join(", ") || "none"}`);
|
||||
console.log(` Notify: ${blocked.notificationPacket.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log("Next action: verify checkpoint/restore evidence, get explicit authorization, compact one Claude seat only, then audit restore with claude-compact-in-place.");
|
||||
}
|
||||
|
||||
export function compactPlanCommand(depsOverride?: CompactPlanDeps): Command {
|
||||
const cmd = new Command("compact-plan")
|
||||
.description("Plan Claude compact-in-place candidates without compacting anything")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig compact-plan Show a read-only Claude compaction triage plan
|
||||
rig compact-plan --rig openrig-pm Plan one rig only
|
||||
rig compact-plan --refresh Re-sample context before planning
|
||||
rig compact-plan --json JSON output for agents`);
|
||||
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig <name>", "Plan one rig only")
|
||||
.option("--refresh", "Re-sample context usage before planning")
|
||||
.option("--threshold-tokens <n>", "Estimated used-token threshold for Claude compact-plan candidates")
|
||||
.option("--threshold-percent <0-100>", "Used-percent threshold when context window size is missing")
|
||||
.action(async (opts: { json?: boolean; rig?: string; refresh?: boolean; thresholdTokens?: string; thresholdPercent?: string }) => {
|
||||
const deps = getDepsF();
|
||||
const thresholds = parseThresholdOptions(opts);
|
||||
if (!thresholds.ok) {
|
||||
console.error(thresholds.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not running. Start it with: rig daemon start");
|
||||
console.error("Cannot build compact-plan without current read-only rig inventory.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
try {
|
||||
const psResult = await client.get<RigEntry[]>("/api/ps");
|
||||
const rigs = psResult.data ?? [];
|
||||
const targetRigs = opts.rig ? rigs.filter((rig) => rig.name === opts.rig) : rigs;
|
||||
|
||||
if (opts.rig && targetRigs.length === 0) {
|
||||
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.refresh && targetRigs.length > 0) {
|
||||
const firstRig = targetRigs[0]!;
|
||||
try {
|
||||
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
|
||||
if (refreshResult.status >= 400) {
|
||||
console.error("Compact-plan refresh failed. Data may be stale.");
|
||||
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
|
||||
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
console.error("Compact-plan refresh failed. Data may be stale.");
|
||||
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
||||
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const allNodes: NodeEntry[] = [];
|
||||
for (const rig of targetRigs) {
|
||||
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
|
||||
if (Array.isArray(nodesResult.data)) {
|
||||
allNodes.push(...nodesResult.data);
|
||||
}
|
||||
}
|
||||
|
||||
const plan = buildPlan(allNodes, thresholds.value);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
} else {
|
||||
printHuman(plan);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error("Fix: check daemon status with: rig daemon status");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function parseThresholdOptions(opts: { thresholdTokens?: string; thresholdPercent?: string }): { ok: true; value: CompactPlanThresholds } | { ok: false; error: string } {
|
||||
const thresholds = defaultThresholds();
|
||||
|
||||
if (opts.thresholdTokens != null) {
|
||||
const value = Number(opts.thresholdTokens);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return { ok: false, error: "--threshold-tokens must be a positive integer" };
|
||||
}
|
||||
thresholds.thresholdTokens = value;
|
||||
}
|
||||
|
||||
if (opts.thresholdPercent != null) {
|
||||
const value = Number(opts.thresholdPercent);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
||||
return { ok: false, error: "--threshold-percent must be a number from 0 to 100" };
|
||||
}
|
||||
thresholds.thresholdPercent = value;
|
||||
}
|
||||
|
||||
return { ok: true, value: thresholds };
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface ContextDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
interface NodeEntry {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
logicalId: string;
|
||||
canonicalSessionName: string | null;
|
||||
runtime: string | null;
|
||||
contextUsage?: {
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
source: string | null;
|
||||
availability: string | null;
|
||||
sampledAt: string | null;
|
||||
fresh: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Seat {
|
||||
session: string;
|
||||
rig: string;
|
||||
logicalId: string;
|
||||
runtime: string;
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
urgency: "critical" | "warning" | "low" | "unknown";
|
||||
freshness: "fresh" | "stale" | "none";
|
||||
status: string;
|
||||
displayStatus: string;
|
||||
source: string | null;
|
||||
availability: string;
|
||||
sampledAt: string | null;
|
||||
staleness: string;
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
const FRESHNESS_THRESHOLD_S = 600; // 10 minutes
|
||||
|
||||
function ageLabel(sampledAt: string | null): string {
|
||||
if (!sampledAt) return "\u2014";
|
||||
const age = Math.max(0, Math.floor((Date.now() - new Date(sampledAt).getTime()) / 1000));
|
||||
if (age < 60) return "<1m ago";
|
||||
const minutes = Math.floor(age / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function computeFreshness(sampledAt: string | null): "fresh" | "stale" | "none" {
|
||||
if (!sampledAt) return "none";
|
||||
const age = (Date.now() - new Date(sampledAt).getTime()) / 1000;
|
||||
return age <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
|
||||
}
|
||||
|
||||
function analyze(node: NodeEntry): Seat {
|
||||
const ctx = node.contextUsage ?? {} as Partial<NonNullable<NodeEntry["contextUsage"]>>;
|
||||
const used = ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null;
|
||||
const remaining = ctx.remainingPercentage != null ? Math.round(ctx.remainingPercentage) : null;
|
||||
const window = ctx.contextWindowSize ?? null;
|
||||
const sampledAt = ctx.sampledAt ?? null;
|
||||
const source = ctx.source ?? null;
|
||||
const availability = ctx.availability ?? (used != null ? "known" : "unknown");
|
||||
|
||||
if (used == null) {
|
||||
return {
|
||||
session: node.canonicalSessionName ?? "",
|
||||
rig: node.rigName ?? "",
|
||||
logicalId: node.logicalId ?? "",
|
||||
runtime: node.runtime ?? "unknown",
|
||||
usedPercentage: null, remainingPercentage: null, contextWindowSize: null,
|
||||
urgency: "unknown", freshness: "none", status: "unknown", displayStatus: "unknown",
|
||||
source, availability, sampledAt, staleness: "\u2014", fresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const urgency: "critical" | "warning" | "low" = used >= 80 ? "critical" : used >= 60 ? "warning" : "low";
|
||||
const freshness = computeFreshness(sampledAt);
|
||||
let status: string;
|
||||
let displayStatus: string;
|
||||
|
||||
if (urgency === "critical") {
|
||||
status = freshness === "fresh" ? "critical" : "critical_stale";
|
||||
displayStatus = freshness === "fresh" ? "CRITICAL" : "CRITICAL (stale)";
|
||||
} else if (urgency === "warning") {
|
||||
status = freshness === "fresh" ? "warning" : "warning_stale";
|
||||
displayStatus = freshness === "fresh" ? "WARNING" : "WARNING (stale)";
|
||||
} else if (freshness === "fresh") {
|
||||
status = "ok";
|
||||
displayStatus = "ok";
|
||||
} else {
|
||||
status = "stale";
|
||||
displayStatus = "stale";
|
||||
}
|
||||
|
||||
return {
|
||||
session: node.canonicalSessionName ?? "",
|
||||
rig: node.rigName ?? "",
|
||||
logicalId: node.logicalId ?? "",
|
||||
runtime: node.runtime ?? "unknown",
|
||||
usedPercentage: used, remainingPercentage: remaining, contextWindowSize: window,
|
||||
urgency, freshness, status, displayStatus,
|
||||
source, availability, sampledAt, staleness: ageLabel(sampledAt), fresh: freshness === "fresh",
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
critical: 0, critical_stale: 0,
|
||||
warning: 1, warning_stale: 1,
|
||||
stale: 2, ok: 3, unknown: 4,
|
||||
};
|
||||
|
||||
function sortSeats(seats: Seat[]): Seat[] {
|
||||
return seats.sort((a, b) => {
|
||||
const ra = STATUS_RANK[a.status] ?? 9;
|
||||
const rb = STATUS_RANK[b.status] ?? 9;
|
||||
if (ra !== rb) return ra - rb;
|
||||
const pa = a.usedPercentage ?? -1;
|
||||
const pb = b.usedPercentage ?? -1;
|
||||
if (pa !== pb) return pb - pa; // higher percentage first
|
||||
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
|
||||
return a.session.localeCompare(b.session);
|
||||
});
|
||||
}
|
||||
|
||||
function isVisible(seat: Seat, threshold: number | null): boolean {
|
||||
if (threshold == null) return true;
|
||||
if (seat.status === "unknown") return true;
|
||||
if (seat.freshness === "stale") return true;
|
||||
return seat.usedPercentage != null && seat.usedPercentage >= threshold;
|
||||
}
|
||||
|
||||
export function contextCommand(depsOverride?: ContextDeps): Command {
|
||||
const cmd = new Command("context")
|
||||
.description("Show context-usage across running agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig context Show all seats with context usage
|
||||
rig context --rig openrig-pm Show one rig
|
||||
rig context --threshold 80 Show seats at or above 80% (plus unknown + stale)
|
||||
rig context --refresh Re-sample context before displaying
|
||||
rig context --json JSON output for agents`);
|
||||
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig <name>", "Show one rig only")
|
||||
.option("--threshold <pct>", "Show seats at or above this percentage")
|
||||
.option("--refresh", "Re-sample context usage before displaying")
|
||||
.action(async (opts: { json?: boolean; rig?: string; threshold?: string; refresh?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// Strict threshold validation — reject non-integer input
|
||||
let threshold: number | null = null;
|
||||
if (opts.threshold != null) {
|
||||
const parsed = Number(opts.threshold);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
|
||||
console.error("rig context: --threshold must be an integer percentage (0-100)");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
threshold = parsed;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
try {
|
||||
// Get rig list to iterate
|
||||
const psResult = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
|
||||
const rigs = psResult.data ?? [];
|
||||
const targetRigs = opts.rig
|
||||
? rigs.filter((r) => r.name === opts.rig)
|
||||
: rigs;
|
||||
|
||||
if (opts.rig && targetRigs.length === 0) {
|
||||
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh once globally if requested (pollOnce samples all active Claude sessions).
|
||||
// Refresh failure is a hard error — do not silently display stale data as if refreshed.
|
||||
if (opts.refresh && targetRigs.length > 0) {
|
||||
const firstRig = targetRigs[0]!;
|
||||
try {
|
||||
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
|
||||
if (refreshResult.status >= 400) {
|
||||
console.error("Context refresh failed. Data may be stale.");
|
||||
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
|
||||
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
console.error("Context refresh failed. Data may be stale.");
|
||||
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
||||
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch node inventory per rig (without refresh — already done globally)
|
||||
const allNodes: NodeEntry[] = [];
|
||||
for (const rig of targetRigs) {
|
||||
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
|
||||
if (Array.isArray(nodesResult.data)) {
|
||||
allNodes.push(...nodesResult.data);
|
||||
}
|
||||
}
|
||||
|
||||
const seats = sortSeats(
|
||||
allNodes
|
||||
.map(analyze)
|
||||
.filter((s) => isVisible(s, threshold))
|
||||
);
|
||||
|
||||
const summary = {
|
||||
total: seats.length,
|
||||
known: seats.filter((s) => s.usedPercentage != null).length,
|
||||
critical: seats.filter((s) => s.urgency === "critical").length,
|
||||
warning: seats.filter((s) => s.urgency === "warning").length,
|
||||
ok: seats.filter((s) => s.status === "ok").length,
|
||||
stale: seats.filter((s) => s.freshness === "stale").length,
|
||||
unknown: seats.filter((s) => s.status === "unknown").length,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ seats, summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
const title = opts.rig ? `CONTEXT USAGE - ${opts.rig}` : "CONTEXT USAGE - all rigs";
|
||||
console.log(title);
|
||||
console.log(
|
||||
`${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} status`
|
||||
);
|
||||
for (const seat of seats) {
|
||||
const context = seat.usedPercentage == null ? "?" : `${seat.usedPercentage}%`;
|
||||
const window = seat.contextWindowSize == null ? "?" : String(seat.contextWindowSize);
|
||||
console.log(
|
||||
`${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${seat.displayStatus}`
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
console.log(
|
||||
`FLEET SUMMARY: ${summary.known}/${summary.total} known | ` +
|
||||
`${summary.critical} CRITICAL | ${summary.warning} WARNING | ` +
|
||||
`${summary.ok} ok | ${summary.stale} stale | ${summary.unknown} unknown`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -140,12 +140,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
.description("Show daemon status")
|
||||
.action(async () => {
|
||||
const status = await getDaemonStatus(getDeps());
|
||||
const pidSuffix = status.pid !== undefined ? ` (pid ${status.pid})` : "";
|
||||
switch (status.state) {
|
||||
case "running":
|
||||
if (status.healthy === false) {
|
||||
console.log(`Daemon running on port ${status.port} (pid ${status.pid}) — healthz failed`);
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix} — healthz failed`);
|
||||
} else {
|
||||
console.log(`Daemon running on port ${status.port} (pid ${status.pid})`);
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix}`);
|
||||
}
|
||||
break;
|
||||
case "stopped":
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
|
||||
import {
|
||||
DESTROY_CONFIRM_TOKEN,
|
||||
buildDestroyPlan,
|
||||
executeDestroy,
|
||||
findListeningPidWithLsof,
|
||||
killTmuxSessionWithCli,
|
||||
listManagedTmuxSessionsFromDb,
|
||||
type DestroyDeps,
|
||||
type ListenerInspection,
|
||||
type DestroyRuntimeConfig,
|
||||
type DestroyScope,
|
||||
} from "../destroy-helpers.js";
|
||||
import { stopDaemon } from "../daemon-lifecycle.js";
|
||||
|
||||
export interface DestroyCommandDeps {
|
||||
configStore: Pick<ConfigStore, "resolve">;
|
||||
destroyDeps: DestroyDeps;
|
||||
}
|
||||
|
||||
interface ResolvedDestroyRuntime {
|
||||
runtimeConfig: DestroyRuntimeConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function resolveRuntimeConfig(configStore: Pick<ConfigStore, "resolve">): ResolvedDestroyRuntime {
|
||||
const warnings: string[] = [];
|
||||
const stateRoot = getPreferredOpenRigHome();
|
||||
const defaultDbPath = getDefaultOpenRigPath("openrig.sqlite");
|
||||
const defaultTranscriptsPath = getDefaultOpenRigPath("transcripts");
|
||||
const effectiveDefaultDbPath = join(stateRoot, "openrig.sqlite");
|
||||
const effectiveDefaultTranscriptsPath = join(stateRoot, "transcripts");
|
||||
const overrideUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL")?.trim();
|
||||
if (overrideUrl) {
|
||||
warnings.push(`Ignoring ${overrideUrl} from OPENRIG_URL/RIGGED_URL because rig destroy only operates on local state.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = configStore.resolve();
|
||||
return {
|
||||
runtimeConfig: {
|
||||
stateRoot,
|
||||
dbPath: config.db.path === defaultDbPath ? effectiveDefaultDbPath : config.db.path,
|
||||
transcriptsPath: config.transcripts.path === defaultTranscriptsPath ? effectiveDefaultTranscriptsPath : config.transcripts.path,
|
||||
daemonHost: config.daemon.host,
|
||||
daemonPort: config.daemon.port,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to read config; using compatibility defaults instead: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return {
|
||||
runtimeConfig: {
|
||||
stateRoot,
|
||||
dbPath: effectiveDefaultDbPath,
|
||||
transcriptsPath: effectiveDefaultTranscriptsPath,
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function realDestroyDeps(): DestroyDeps {
|
||||
const lifecycleDeps = realDeps();
|
||||
return {
|
||||
stopDaemon: async () => stopDaemon(lifecycleDeps),
|
||||
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/healthz`);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: "other_http",
|
||||
healthy: false,
|
||||
detail: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
return {
|
||||
kind: "other_http",
|
||||
healthy: false,
|
||||
detail: "Response body was not valid OpenRig health JSON.",
|
||||
};
|
||||
}
|
||||
|
||||
if (body && typeof body === "object" && (body as Record<string, unknown>).status === "ok") {
|
||||
return { kind: "openrig", healthy: true };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "other_http",
|
||||
healthy: false,
|
||||
detail: "Response body did not match the OpenRig health contract.",
|
||||
};
|
||||
} catch {
|
||||
return { kind: "unreachable" };
|
||||
}
|
||||
},
|
||||
findListeningPid: findListeningPidWithLsof,
|
||||
killProcess: (pid: number) => {
|
||||
process.kill(pid, "SIGTERM");
|
||||
},
|
||||
exists: existsSync,
|
||||
renamePath: (from: string, to: string) => renameSync(from, to),
|
||||
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
|
||||
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
|
||||
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
|
||||
killTmuxSession: killTmuxSessionWithCli,
|
||||
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
now: () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function printPlan(
|
||||
scope: DestroyScope,
|
||||
backup: boolean,
|
||||
runtimeConfig: DestroyRuntimeConfig,
|
||||
managedTmuxSessions: string[],
|
||||
warnings: string[]
|
||||
): void {
|
||||
console.log("DESTROY PLAN");
|
||||
console.log(` scope: ${scope}`);
|
||||
console.log(` state root: ${runtimeConfig.stateRoot}`);
|
||||
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
|
||||
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
|
||||
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
|
||||
for (const warning of warnings) {
|
||||
console.log(` warning: ${warning}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printResult(result: Awaited<ReturnType<typeof executeDestroy>>): void {
|
||||
console.log("DESTROY RESULT");
|
||||
console.log(` daemon: ${result.daemonStopped ? "stopped" : "still responding"}`);
|
||||
console.log(` port: ${result.portCleared ? "cleared" : "still occupied"}`);
|
||||
if (result.scope === "all") {
|
||||
console.log(` tmux sessions removed: ${result.tmuxKilled}`);
|
||||
if (result.tmuxMissing > 0) {
|
||||
console.log(` tmux sessions missing/already gone: ${result.tmuxMissing}`);
|
||||
}
|
||||
}
|
||||
if (result.backupPaths.length > 0) {
|
||||
for (const backupPath of result.backupPaths) {
|
||||
console.log(` backup: ${backupPath}`);
|
||||
}
|
||||
} else if (result.backup) {
|
||||
console.log(" backup: nothing to move");
|
||||
}
|
||||
console.log(result.stateRecreated ? ` fresh state root: ${result.stateRoot}` : " fresh state root: not created (state left untouched)");
|
||||
for (const warning of result.warnings) {
|
||||
console.log(` warning: ${warning}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyCommand(depsOverride?: DestroyCommandDeps): Command {
|
||||
const cmd = new Command("destroy").description("Destroy OpenRig local state for recovery");
|
||||
|
||||
cmd
|
||||
.option("--state", "Destroy OpenRig state and recreate an empty state root")
|
||||
.option("--all", "Destroy OpenRig state and remove managed tmux sessions")
|
||||
.option("--backup", "Move state aside instead of deleting it")
|
||||
.option("--yes", "Confirm the destructive operation")
|
||||
.option("--confirm <token>", `Exact confirmation token: ${DESTROY_CONFIRM_TOKEN}`)
|
||||
.action(async (opts: { state?: boolean; all?: boolean; backup?: boolean; yes?: boolean; confirm?: string }) => {
|
||||
const selectedScopes = [opts.state ? "state" : null, opts.all ? "all" : null].filter(Boolean) as DestroyScope[];
|
||||
if (selectedScopes.length !== 1) {
|
||||
console.error("Specify exactly one destroy scope: --state or --all");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!opts.yes) {
|
||||
console.error("Destroy requires --yes");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.confirm !== DESTROY_CONFIRM_TOKEN) {
|
||||
console.error(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = depsOverride ?? {
|
||||
configStore: new ConfigStore(),
|
||||
destroyDeps: realDestroyDeps(),
|
||||
};
|
||||
|
||||
const { runtimeConfig, warnings } = resolveRuntimeConfig(deps.configStore);
|
||||
const plan = buildDestroyPlan(selectedScopes[0]!, Boolean(opts.backup), runtimeConfig, deps.destroyDeps);
|
||||
printPlan(plan.scope, plan.backup, runtimeConfig, plan.managedTmuxSessions, [...warnings, ...plan.warnings]);
|
||||
plan.warnings = [...warnings, ...plan.warnings];
|
||||
const result = await executeDestroy(plan, deps.destroyDeps);
|
||||
printResult(result);
|
||||
if (!result.portCleared) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Command } from "commander";
|
||||
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
|
||||
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { buildWritableHomeCheck } from "../system-preflight.js";
|
||||
import {
|
||||
CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
isCmuxSocketControlCompatible,
|
||||
readCmuxSocketControlModeFromText,
|
||||
resolveCmuxSettingsPath,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
|
||||
interface DoctorCheck {
|
||||
name: string;
|
||||
status: "pass" | "warn" | "fail";
|
||||
status: "pass" | "warn" | "fail" | "skipped";
|
||||
message: string;
|
||||
reason?: string;
|
||||
fix?: string;
|
||||
@@ -18,11 +25,14 @@ interface DoctorCheck {
|
||||
export interface DoctorDeps {
|
||||
exists: (p: string) => boolean;
|
||||
baseDir: string;
|
||||
readFile: (path: string) => string | null;
|
||||
exec: (cmd: string) => string;
|
||||
checkPort: (port: number) => Promise<boolean>;
|
||||
configStore: Pick<ConfigStore, "resolve">;
|
||||
platform?: NodeJS.Platform;
|
||||
mkdirp?: (path: string) => void;
|
||||
checkWritable?: (path: string) => void;
|
||||
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
|
||||
}
|
||||
|
||||
const MIN_NODE_MAJOR = 20;
|
||||
@@ -39,8 +49,9 @@ function defaultCheckPort(port: number): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck> } {
|
||||
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck>; asyncChecks: Promise<DoctorCheck>[] } {
|
||||
const checks: DoctorCheck[] = [];
|
||||
const platform = deps.platform ?? process.platform;
|
||||
|
||||
// 1. Daemon dist
|
||||
const daemonPath = resolveDaemonPath(deps.baseDir, deps.exists);
|
||||
@@ -86,10 +97,8 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
}
|
||||
|
||||
// 4. tmux
|
||||
try {
|
||||
const ver = deps.exec("tmux -V").trim();
|
||||
checks.push({ name: "tmux", status: "pass", message: ver });
|
||||
} catch {
|
||||
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
|
||||
if (tmuxProbe.code === "not_installed") {
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
status: "fail",
|
||||
@@ -97,9 +106,78 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
reason: "OpenRig uses tmux to manage agent sessions.",
|
||||
fix: "Install tmux: brew install tmux (macOS), apt install tmux (Linux).",
|
||||
});
|
||||
} else if (tmuxProbe.available && tmuxProbe.version) {
|
||||
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
|
||||
if (platform === "darwin") {
|
||||
const mouseMode = readTmuxMouseMode(deps);
|
||||
if (mouseMode === "on") {
|
||||
checks.push({
|
||||
name: "tmux_mouse",
|
||||
status: "pass",
|
||||
message: "tmux mouse mode enabled.",
|
||||
});
|
||||
} else if (mouseMode === "off") {
|
||||
checks.push({
|
||||
name: "tmux_mouse",
|
||||
status: "warn",
|
||||
message: "tmux mouse mode appears disabled.",
|
||||
reason: "On macOS, scrolling and text selection inside tmux panes is much smoother with mouse mode enabled.",
|
||||
fix: "Run `tmux set -g mouse on` for the current tmux server. To keep it enabled, add `set -g mouse on` to `~/.tmux.conf` and reload with `tmux source-file ~/.tmux.conf`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
status: "fail",
|
||||
message: failure.message,
|
||||
reason: failure.reason,
|
||||
fix: failure.fix,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Writable state paths (shared with preflight)
|
||||
// 5. cmux shell check (optional but recommended for Open CMUX workflows)
|
||||
let shellCmuxPassed = false;
|
||||
try {
|
||||
deps.exec("cmux capabilities --json");
|
||||
shellCmuxPassed = true;
|
||||
checks.push({
|
||||
name: "cmux_shell",
|
||||
status: "pass",
|
||||
message: "cmux shell control available.",
|
||||
});
|
||||
} catch (err) {
|
||||
try {
|
||||
deps.exec("cmux --help");
|
||||
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
|
||||
const modeHint = socketMode?.error
|
||||
? ` Likely cause on macOS: ${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable (${socketMode.error}).`
|
||||
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode) && socketMode.source === "default"
|
||||
? ` Likely cause on macOS: cmux is still using its default automation.socketControlMode '${socketMode.mode}'.`
|
||||
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode)
|
||||
? ` Likely cause on macOS: automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}.`
|
||||
: "";
|
||||
checks.push({
|
||||
name: "cmux_shell",
|
||||
status: "warn",
|
||||
message: "cmux installed, but control unavailable right now.",
|
||||
reason: "OpenRig can run without cmux, but Open CMUX actions and cmux-aware node control will be unavailable until cmux control works.",
|
||||
fix: `Open the cmux app, verify control access/socket sharing is enabled for OpenRig, then rerun 'rig doctor'. If you are running this for someone else, tell the user that cmux is optional but required for Open CMUX. If you do not need cmux features, you can ignore this warning.${modeHint}`,
|
||||
});
|
||||
} catch {
|
||||
checks.push({
|
||||
name: "cmux_shell",
|
||||
status: "warn",
|
||||
message: "cmux not found.",
|
||||
reason: "OpenRig can run without cmux, but Open CMUX actions and surface control will be unavailable.",
|
||||
fix: "Install and launch cmux if you want Open CMUX support. If you are running this for someone else, tell the user that cmux is optional and only needed for Open CMUX workflows.",
|
||||
});
|
||||
}
|
||||
void err;
|
||||
}
|
||||
|
||||
// 6. Writable state paths (shared with preflight)
|
||||
const config = deps.configStore.resolve();
|
||||
const writableCheck = buildWritableHomeCheck(config, path.dirname(config.db.path), {
|
||||
mkdirp: deps.mkdirp,
|
||||
@@ -113,7 +191,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
fix: writableCheck.fix,
|
||||
});
|
||||
|
||||
// 6. Port availability (async) — daemon already running on that port counts as OK
|
||||
// 7. Port availability (async) — daemon already running on that port counts as OK
|
||||
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
|
||||
if (available) {
|
||||
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
|
||||
@@ -134,7 +212,86 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
};
|
||||
});
|
||||
|
||||
return { checks, portCheck };
|
||||
// 8. Daemon cmux control (async, only when shell cmux passed)
|
||||
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
|
||||
if (shellCmuxPassed) {
|
||||
const fetchFn = deps.fetch ?? globalThis.fetch;
|
||||
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
|
||||
try {
|
||||
const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
|
||||
if (!healthRes.ok) {
|
||||
return { name: "cmux_daemon", status: "skipped", message: "Daemon not running. Skipping daemon cmux check." };
|
||||
}
|
||||
} catch {
|
||||
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
|
||||
}
|
||||
|
||||
try {
|
||||
const cmuxRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/api/adapters/cmux/status`);
|
||||
if (cmuxRes.ok && cmuxRes.json) {
|
||||
const data = (await cmuxRes.json()) as { available?: boolean };
|
||||
if (data.available) {
|
||||
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
|
||||
}
|
||||
return buildCmuxDaemonWarning(deps, platform);
|
||||
}
|
||||
} catch { /* fetch failed */ }
|
||||
|
||||
return buildCmuxDaemonWarning(deps, platform);
|
||||
})();
|
||||
asyncChecks.push(daemonCmuxCheck);
|
||||
}
|
||||
|
||||
return { checks, portCheck, asyncChecks };
|
||||
}
|
||||
|
||||
function readCmuxSocketControlMode(deps: DoctorDeps) {
|
||||
return readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
|
||||
}
|
||||
|
||||
function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): DoctorCheck {
|
||||
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
|
||||
|
||||
if (platform === "darwin" && socketMode?.error) {
|
||||
return {
|
||||
name: "cmux_daemon",
|
||||
status: "warn",
|
||||
message: "Shell cmux works, but the daemon cannot control cmux.",
|
||||
reason: `${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable: ${socketMode.error}`,
|
||||
fix: `Repair ${CMUX_SETTINGS_DISCLOSURE_PATH} or remove it so cmux can regenerate the template, then rerun \`rig setup\` or \`rig doctor\`.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === "darwin" && socketMode && !isCmuxSocketControlCompatible(socketMode.mode)) {
|
||||
const reason = socketMode.source === "default"
|
||||
? `cmux is still using its default automation.socketControlMode '${socketMode.mode}', so the daemon cannot attach as an external cmux client yet.`
|
||||
: `automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}, so the daemon cannot attach as an external cmux client yet.`;
|
||||
return {
|
||||
name: "cmux_daemon",
|
||||
status: "warn",
|
||||
message: "Shell cmux works, but the daemon cannot control cmux.",
|
||||
reason,
|
||||
fix: `Run \`rig setup\` to set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then restart the daemon with \`rig daemon start\`.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "cmux_daemon",
|
||||
status: "warn",
|
||||
message: "Shell cmux works, but the daemon cannot control cmux.",
|
||||
reason: "The daemon inherited a terminal/session environment that broke cmux adapter initialization.",
|
||||
fix: "Restart the daemon with `rig daemon start` after setup. If it still fails, inspect the daemon log with `rig daemon logs`.",
|
||||
};
|
||||
}
|
||||
|
||||
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
|
||||
try {
|
||||
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
|
||||
if (mode === "on" || mode === "off") return mode;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
@@ -146,34 +303,35 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
const deps: DoctorDeps = depsOverride ?? {
|
||||
exists: existsSync,
|
||||
baseDir: import.meta.dirname,
|
||||
exec: (c: string) => execSync(c, { encoding: "utf-8" }),
|
||||
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
checkPort: defaultCheckPort,
|
||||
configStore: new ConfigStore(),
|
||||
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
|
||||
checkWritable: (dirPath: string) => accessSync(dirPath, constants.W_OK),
|
||||
};
|
||||
|
||||
const { checks, portCheck } = runDoctorChecks(deps);
|
||||
const portResult = await portCheck;
|
||||
const allChecks = [...checks, portResult];
|
||||
const allPass = allChecks.every((c) => c.status === "pass");
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolvedAsync = await Promise.all(asyncChecks);
|
||||
const allChecks = [...checks, ...resolvedAsync];
|
||||
const healthy = allChecks.every((c) => c.status !== "fail");
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ healthy: allPass, checks: allChecks }, null, 2));
|
||||
if (!allPass) process.exitCode = 1;
|
||||
console.log(JSON.stringify({ healthy, checks: allChecks }, null, 2));
|
||||
if (!healthy) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const check of allChecks) {
|
||||
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : "FAIL";
|
||||
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : check.status === "skipped" ? "SKIP" : "FAIL";
|
||||
console.log(` [${icon}] ${check.name}: ${check.message}`);
|
||||
if (check.reason) console.log(` Why: ${check.reason}`);
|
||||
if (check.fix) console.log(` Fix: ${check.fix}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(allPass ? "All checks passed." : "Some checks failed.");
|
||||
if (!allPass) process.exitCode = 1;
|
||||
console.log(healthy ? "System checks look good." : "Some checks failed.");
|
||||
if (!healthy) process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -14,6 +14,8 @@ interface TeardownResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
/**
|
||||
* `rig down <rigId>` — tear down a rig.
|
||||
* @param depsOverride - injectable deps for testing
|
||||
@@ -46,7 +48,7 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
delete: opts.delete ?? false,
|
||||
force: opts.force ?? false,
|
||||
snapshot: opts.snapshot ?? false,
|
||||
});
|
||||
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
export function envCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("env")
|
||||
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig env status secrets-manager
|
||||
rig env logs secrets-manager vault
|
||||
rig env down secrets-manager
|
||||
`);
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running");
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
async function resolveRigId(client: DaemonClient, rigRef: string): Promise<string> {
|
||||
const summaries = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
|
||||
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
|
||||
if (!match) throw new Error(`Rig '${rigRef}' not found`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
// rig env status <rig>
|
||||
cmd
|
||||
.command("status")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rig: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
let rigId: string;
|
||||
try { rigId = await resolveRigId(client, rig); } catch (err) {
|
||||
console.error((err as Error).message); process.exitCode = 1; return;
|
||||
}
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.data["hasServices"]) {
|
||||
console.log("No services configured for this rig.");
|
||||
return;
|
||||
}
|
||||
|
||||
const receipt = res.data["receipt"] as Record<string, unknown> | null;
|
||||
if (!receipt) {
|
||||
console.log("Services configured but no receipt available yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Env: ${res.data["kind"]} (${res.data["projectName"]})`);
|
||||
const services = receipt["services"] as Array<{ name: string; status: string; health?: string | null }>;
|
||||
if (services) {
|
||||
for (const svc of services) {
|
||||
const health = svc.health ? ` (${svc.health})` : "";
|
||||
console.log(` ${svc.name}: ${svc.status}${health}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// rig env logs <rig> [service]
|
||||
cmd
|
||||
.command("logs")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.argument("[service]", "Specific service name")
|
||||
.option("--tail <n>", "Number of lines", "100")
|
||||
.action(async (rig: string, service: string | undefined, opts: { tail: string }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
let rigId: string;
|
||||
try { rigId = await resolveRigId(client, rig); } catch (err) {
|
||||
console.error((err as Error).message); process.exitCode = 1; return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ tail: opts.tail });
|
||||
if (service) params.set("service", service);
|
||||
|
||||
const res = await client.get<{ ok: boolean; output?: string; error?: string }>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/env/logs?${params}`,
|
||||
);
|
||||
|
||||
if (res.status >= 400 || !res.data.ok) {
|
||||
console.error(res.data.error ?? `Failed to get logs (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(res.data.output ?? "");
|
||||
});
|
||||
|
||||
// rig env down <rig> [--volumes]
|
||||
cmd
|
||||
.command("down")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.option("--volumes", "Also remove volumes")
|
||||
.action(async (rig: string, opts: { volumes?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
let rigId: string;
|
||||
try { rigId = await resolveRigId(client, rig); } catch (err) {
|
||||
console.error((err as Error).message); process.exitCode = 1; return;
|
||||
}
|
||||
|
||||
const res = await client.post<{ ok: boolean; error?: string }>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/env/down`,
|
||||
{ volumes: opts.volumes ?? false },
|
||||
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (res.status >= 400 || !res.data.ok) {
|
||||
console.error(res.data.error ?? `Failed to stop services (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Services stopped for ${rig}.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -102,13 +102,23 @@ export function expandCommand(depsOverride?: StatusDeps): Command {
|
||||
for (const w of data.warnings) console.log(` Warning: ${w}`);
|
||||
}
|
||||
|
||||
// Honest retry guidance for failed nodes
|
||||
// Honest retry guidance for failed nodes. The pod-aware /launch route
|
||||
// returns pod_aware_launch_unsupported for any node `expand` creates
|
||||
// (every expand-created node has podId set), so the recoverable path is
|
||||
// shrink-the-failed-pod + fix + re-expand, not per-node relaunch.
|
||||
if (data.retryTargets && data.retryTargets.length > 0) {
|
||||
console.log("");
|
||||
console.log(" Failed nodes can be relaunched via the dashboard or CLI:");
|
||||
for (const target of data.retryTargets) {
|
||||
console.log(` rig launch ${rigId} ${target}`);
|
||||
console.log(" Failed nodes cannot be relaunched directly because pod-aware launch");
|
||||
console.log(" bypasses startup orchestration. Recover by:");
|
||||
console.log(` 1. Verify other seats are healthy: rig ps --filter rigName=${rigId} --nodes`);
|
||||
if (data.podNamespace) {
|
||||
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} ${data.podNamespace}`);
|
||||
} else {
|
||||
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} <pod-namespace>`);
|
||||
}
|
||||
console.log(` 3. Fix the spec/config issue.`);
|
||||
console.log(` 4. Re-run: rig expand ${rigId} <pod-fragment-path>`);
|
||||
console.log(` Failed targets: ${data.retryTargets.join(", ")}`);
|
||||
}
|
||||
|
||||
if (data.status !== "ok") {
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { Command } from "commander";
|
||||
|
||||
export type HeartbeatExecutionState =
|
||||
| "checked-out"
|
||||
| "proven-active"
|
||||
| "stalled"
|
||||
| "unproven"
|
||||
| "blocked"
|
||||
| "parked"
|
||||
| "done";
|
||||
|
||||
export interface HeartbeatProof {
|
||||
at: string;
|
||||
ageSeconds: number;
|
||||
line: string;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export interface HeartbeatItem {
|
||||
id: string;
|
||||
rig: string;
|
||||
owner: string;
|
||||
session: string;
|
||||
title: string;
|
||||
queueState: string;
|
||||
executionState: HeartbeatExecutionState;
|
||||
queueFile: string;
|
||||
checkoutAt: string | null;
|
||||
checkoutAgeSeconds: number | null;
|
||||
lastProof: HeartbeatProof | null;
|
||||
blockedOn: string | null;
|
||||
}
|
||||
|
||||
export interface HeartbeatSummary {
|
||||
total: number;
|
||||
checkedOut: number;
|
||||
provenActive: number;
|
||||
stalled: number;
|
||||
unproven: number;
|
||||
blocked: number;
|
||||
parked: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface HeartbeatResult {
|
||||
generatedAt: string;
|
||||
sharedDocsRoot: string;
|
||||
rigFilter: string | null;
|
||||
windows: {
|
||||
firstProofSeconds: number;
|
||||
heartbeatSeconds: number;
|
||||
};
|
||||
summary: HeartbeatSummary;
|
||||
items: HeartbeatItem[];
|
||||
nudgeResults?: HeartbeatNudgeResult[];
|
||||
}
|
||||
|
||||
export interface HeartbeatNudgeResult {
|
||||
id: string;
|
||||
session: string;
|
||||
executionState: "stalled" | "unproven";
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface QueueEntry {
|
||||
id: string;
|
||||
rig: string;
|
||||
owner: string;
|
||||
session: string;
|
||||
title: string;
|
||||
queueState: string;
|
||||
queueFile: string;
|
||||
meta: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatOptions {
|
||||
rig?: string;
|
||||
firstProofSeconds?: number;
|
||||
heartbeatSeconds?: number;
|
||||
includeDone?: boolean;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface HeartbeatDeps {
|
||||
sharedDocsRoot?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => Date;
|
||||
send?: (session: string, text: string) => Promise<{ ok: boolean; message: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_FIRST_PROOF_SECONDS = 7_200;
|
||||
const DEFAULT_HEARTBEAT_SECONDS = 7_200;
|
||||
const TIMESTAMP_PATTERN = String.raw`\d{4}-\d{2}-\d{2}T[\d:~]+(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`;
|
||||
const STATE_RANK: Record<HeartbeatExecutionState, number> = {
|
||||
stalled: 0,
|
||||
unproven: 1,
|
||||
blocked: 2,
|
||||
"checked-out": 3,
|
||||
"proven-active": 4,
|
||||
parked: 5,
|
||||
done: 6,
|
||||
};
|
||||
|
||||
export function heartbeatCommand(depsOverride?: HeartbeatDeps): Command {
|
||||
const cmd = new Command("heartbeat")
|
||||
.description("Show workflow execution proof state from queue files")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig heartbeat --rig openrig-pm
|
||||
rig heartbeat --rig openrig-pm --json
|
||||
rig heartbeat --rig openrig-pm --nudge
|
||||
|
||||
Default mode is read-only. --nudge only sends informational proof instructions
|
||||
to stalled/unproven owners; it does not modify queue files or reroute work.`);
|
||||
|
||||
cmd
|
||||
.option("--rig <name>", "Limit to a single rig")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--nudge", "Send informational nudges to stalled/unproven owners")
|
||||
.option("--include-done", "Include done/handed-off queue items in output")
|
||||
.action(async (opts: { rig?: string; json?: boolean; nudge?: boolean; includeDone?: boolean }) => {
|
||||
const deps = depsOverride ?? {};
|
||||
const env = deps.env ?? process.env;
|
||||
const sharedDocsRoot = deps.sharedDocsRoot ?? resolveSharedDocsRoot(env);
|
||||
if (!sharedDocsRoot) {
|
||||
console.error("rig heartbeat: cannot resolve shared-docs root. Set RIGX_SHARED_DOCS_ROOT.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const firstProofSeconds = readPositiveInt(env["HEARTBEAT_FIRST_PROOF_WINDOW"], DEFAULT_FIRST_PROOF_SECONDS);
|
||||
const heartbeatSeconds = readPositiveInt(env["HEARTBEAT_HEARTBEAT_CADENCE"], DEFAULT_HEARTBEAT_SECONDS);
|
||||
const now = deps.now?.() ?? new Date();
|
||||
const result = analyzeHeartbeat({
|
||||
sharedDocsRoot,
|
||||
rig: opts.rig,
|
||||
firstProofSeconds,
|
||||
heartbeatSeconds,
|
||||
includeDone: !!opts.includeDone,
|
||||
now,
|
||||
});
|
||||
|
||||
if (opts.nudge) {
|
||||
const send = deps.send ?? defaultSend;
|
||||
result.nudgeResults = await sendHeartbeatNudges(result.items, send);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printHumanHeartbeat(result);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function analyzeHeartbeat(input: {
|
||||
sharedDocsRoot: string;
|
||||
rig?: string;
|
||||
firstProofSeconds?: number;
|
||||
heartbeatSeconds?: number;
|
||||
includeDone?: boolean;
|
||||
now?: Date;
|
||||
}): HeartbeatResult {
|
||||
const firstProofSeconds = input.firstProofSeconds ?? DEFAULT_FIRST_PROOF_SECONDS;
|
||||
const heartbeatSeconds = input.heartbeatSeconds ?? DEFAULT_HEARTBEAT_SECONDS;
|
||||
const now = input.now ?? new Date();
|
||||
const generatedAt = now.toISOString();
|
||||
const queueFiles = discoverQueueFiles(input.sharedDocsRoot, input.rig);
|
||||
const entries = queueFiles.flatMap((file) => parseQueueFile(input.sharedDocsRoot, file));
|
||||
const items = entries
|
||||
.map((entry) => classifyQueueEntry(entry, { now, firstProofSeconds, heartbeatSeconds }))
|
||||
.filter((item): item is HeartbeatItem => item !== null)
|
||||
.filter((item) => input.includeDone || item.executionState !== "done")
|
||||
.sort(compareHeartbeatItems);
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
sharedDocsRoot: input.sharedDocsRoot,
|
||||
rigFilter: input.rig ?? null,
|
||||
windows: { firstProofSeconds, heartbeatSeconds },
|
||||
summary: summarize(items),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseQueueFile(sharedDocsRoot: string, queueFile: string): QueueEntry[] {
|
||||
const content = fs.readFileSync(queueFile, "utf8");
|
||||
const rig = deriveRigName(sharedDocsRoot, queueFile);
|
||||
const owner = deriveOwner(queueFile);
|
||||
const session = deriveSessionName(owner, rig);
|
||||
const entries: QueueEntry[] = [];
|
||||
const entryPattern = new RegExp(
|
||||
String.raw`(?:^|\n)---\nid:\s*(\S+)\n([\s\S]*?)\n---\n([\s\S]*?)(?=\n---\nid:\s*\S+\n|$)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
for (const match of content.matchAll(entryPattern)) {
|
||||
const id = match[1]!;
|
||||
const meta = parseMetadata(match[2] ?? "");
|
||||
meta["id"] = id;
|
||||
const body = match[3] ?? "";
|
||||
const queueState = normalizeState(meta["state"] ?? "unknown");
|
||||
const title = body.match(/^###\s+(.+)$/m)?.[1]?.trim() ?? id;
|
||||
entries.push({
|
||||
id,
|
||||
rig,
|
||||
owner,
|
||||
session,
|
||||
title: title.slice(0, 120),
|
||||
queueState,
|
||||
queueFile,
|
||||
meta,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function classifyQueueEntry(
|
||||
entry: QueueEntry,
|
||||
opts: { now: Date; firstProofSeconds: number; heartbeatSeconds: number },
|
||||
): HeartbeatItem | null {
|
||||
const blockedOn = cleanNullable(entry.meta["blocked-on"]);
|
||||
if (entry.queueState === "blocked" || entry.queueState === "blocked-on") {
|
||||
return baseItem(entry, "blocked", opts.now, null, null, blockedOn);
|
||||
}
|
||||
if (entry.queueState === "deferred" || entry.queueState === "parked") {
|
||||
return baseItem(entry, "parked", opts.now, null, null, blockedOn);
|
||||
}
|
||||
if (entry.queueState === "done" || entry.queueState === "handed-off") {
|
||||
return baseItem(entry, "done", opts.now, null, null, blockedOn);
|
||||
}
|
||||
if (entry.queueState !== "in-progress") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkoutAt = firstStateTransitionTimestamp(entry.body, "in-progress")
|
||||
?? cleanNullable(entry.meta["ts-created"]);
|
||||
const checkoutAgeSeconds = ageSeconds(checkoutAt, opts.now);
|
||||
const lastProof = latestProof(entry.body, opts.now);
|
||||
|
||||
let executionState: HeartbeatExecutionState;
|
||||
if (lastProof && lastProof.ageSeconds <= opts.heartbeatSeconds) {
|
||||
executionState = "proven-active";
|
||||
} else if (lastProof) {
|
||||
executionState = "stalled";
|
||||
} else if (checkoutAgeSeconds != null && checkoutAgeSeconds > opts.firstProofSeconds) {
|
||||
executionState = "unproven";
|
||||
} else {
|
||||
executionState = "checked-out";
|
||||
}
|
||||
|
||||
return baseItem(entry, executionState, opts.now, checkoutAt, lastProof, blockedOn);
|
||||
}
|
||||
|
||||
export async function sendHeartbeatNudges(
|
||||
items: HeartbeatItem[],
|
||||
send: (session: string, text: string) => Promise<{ ok: boolean; message: string }>,
|
||||
): Promise<HeartbeatNudgeResult[]> {
|
||||
const targets = items.filter((item): item is HeartbeatItem & { executionState: "stalled" | "unproven" } =>
|
||||
item.executionState === "stalled" || item.executionState === "unproven"
|
||||
);
|
||||
const results: HeartbeatNudgeResult[] = [];
|
||||
|
||||
for (const item of targets) {
|
||||
const proofCopy = item.executionState === "stalled" && item.lastProof
|
||||
? `Last proof was ${formatAge(item.lastProof.ageSeconds)} ago.`
|
||||
: `No proof-of-work since checkout${item.checkoutAgeSeconds == null ? "." : ` ${formatAge(item.checkoutAgeSeconds)} ago.`}`;
|
||||
const text = [
|
||||
`[heartbeat-nudge] Your task \`${item.id}\` is ${item.executionState}. ${proofCopy}`,
|
||||
"If actively working, add a task-specific proof note to your queue item naming the artifact.",
|
||||
"If blocked, transition to blocked with reason. If pausing, transition to deferred with reason.",
|
||||
].join(" ");
|
||||
const result = await send(item.session, text);
|
||||
results.push({ id: item.id, session: item.session, executionState: item.executionState, ...result });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolveSharedDocsRoot(env: NodeJS.ProcessEnv): string | null {
|
||||
for (const key of ["RIGX_SHARED_DOCS_ROOT", "OPENRIG_SHARED_DOCS_ROOT"]) {
|
||||
const value = env[key];
|
||||
if (value && fs.existsSync(nodePath.join(value, "rigs"))) return value;
|
||||
}
|
||||
|
||||
const home = env["HOME"] ?? os.homedir();
|
||||
const wellKnown = nodePath.join(home, "code", "substrate", "shared-docs");
|
||||
if (fs.existsSync(nodePath.join(wellKnown, "rigs"))) return wellKnown;
|
||||
return null;
|
||||
}
|
||||
|
||||
function discoverQueueFiles(sharedDocsRoot: string, rig?: string): string[] {
|
||||
const base = rig
|
||||
? nodePath.join(sharedDocsRoot, "rigs", rig, "state")
|
||||
: nodePath.join(sharedDocsRoot, "rigs");
|
||||
const files: string[] = [];
|
||||
walk(base, files);
|
||||
return files.filter((file) => file.endsWith(".queue.md")).sort();
|
||||
}
|
||||
|
||||
function walk(dir: string, files: string[]): void {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = nodePath.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath, files);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadata(text: string): Record<string, string> {
|
||||
const meta: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (match) meta[match[1]!.toLowerCase()] = match[2]!.trim();
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function normalizeState(state: string): string {
|
||||
return state.trim().toLowerCase().replace(/_/g, "-");
|
||||
}
|
||||
|
||||
function firstStateTransitionTimestamp(body: string, state: string): string | null {
|
||||
const pattern = new RegExp(`(${TIMESTAMP_PATTERN})\\s*[-\u2014]\\s*${escapeRegex(state).replace("-", "[- ]")}`, "gi");
|
||||
const matches = Array.from(body.matchAll(pattern)).map((match) => match[1]!.replace("~", ""));
|
||||
return matches.length > 0 ? matches[0]! : null;
|
||||
}
|
||||
|
||||
function latestProof(body: string, now: Date): HeartbeatProof | null {
|
||||
const proofs: HeartbeatProof[] = [];
|
||||
for (const rawLine of body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!/\bproof\b/i.test(line)) continue;
|
||||
const timestamp = line.match(new RegExp(`(${TIMESTAMP_PATTERN})`, "i"))?.[1]?.replace("~", "");
|
||||
if (!timestamp) continue;
|
||||
const path = extractEvidencePath(line);
|
||||
if (!path) continue;
|
||||
const age = ageSeconds(timestamp, now);
|
||||
if (age == null) continue;
|
||||
proofs.push({ at: timestamp, ageSeconds: age, line, path });
|
||||
}
|
||||
|
||||
proofs.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime());
|
||||
return proofs.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function extractEvidencePath(line: string): string | null {
|
||||
const backtickPath = Array.from(line.matchAll(/`([^`]+)`/g))
|
||||
.map((match) => match[1]!)
|
||||
.find(looksLikePath);
|
||||
if (backtickPath) return backtickPath;
|
||||
|
||||
const tokenPath = line
|
||||
.split(/\s+/)
|
||||
.map((token) => token.replace(/[),.;:]+$/g, "").replace(/^["']|["']$/g, ""))
|
||||
.find(looksLikePath);
|
||||
return tokenPath ?? null;
|
||||
}
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
if (value.startsWith("/")) return value.length > 1;
|
||||
if (value.startsWith("./") || value.startsWith("../")) return true;
|
||||
if (/^[A-Za-z0-9_.-]+\.(md|txt|ts|tsx|js|jsx|json|jsonl|yaml|yml|toml|sqlite|db)$/i.test(value)) return true;
|
||||
return /[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+/.test(value);
|
||||
}
|
||||
|
||||
function baseItem(
|
||||
entry: QueueEntry,
|
||||
executionState: HeartbeatExecutionState,
|
||||
now: Date,
|
||||
checkoutAt: string | null,
|
||||
lastProof: HeartbeatProof | null,
|
||||
blockedOn: string | null,
|
||||
): HeartbeatItem {
|
||||
return {
|
||||
id: entry.id,
|
||||
rig: entry.rig,
|
||||
owner: entry.owner,
|
||||
session: entry.session,
|
||||
title: entry.title,
|
||||
queueState: entry.queueState,
|
||||
executionState,
|
||||
queueFile: entry.queueFile,
|
||||
checkoutAt,
|
||||
checkoutAgeSeconds: ageSeconds(checkoutAt, now),
|
||||
lastProof,
|
||||
blockedOn,
|
||||
};
|
||||
}
|
||||
|
||||
function ageSeconds(ts: string | null, now: Date): number | null {
|
||||
if (!ts) return null;
|
||||
const parsed = parseTimestamp(ts);
|
||||
if (!parsed) return null;
|
||||
return Math.max(0, Math.floor((now.getTime() - parsed.getTime()) / 1000));
|
||||
}
|
||||
|
||||
function parseTimestamp(raw: string): Date | null {
|
||||
const normalized = raw.trim().replace("~", "");
|
||||
const iso = /Z$|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : `${normalized}Z`;
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function summarize(items: HeartbeatItem[]): HeartbeatSummary {
|
||||
return {
|
||||
total: items.length,
|
||||
checkedOut: items.filter((item) => item.executionState === "checked-out").length,
|
||||
provenActive: items.filter((item) => item.executionState === "proven-active").length,
|
||||
stalled: items.filter((item) => item.executionState === "stalled").length,
|
||||
unproven: items.filter((item) => item.executionState === "unproven").length,
|
||||
blocked: items.filter((item) => item.executionState === "blocked").length,
|
||||
parked: items.filter((item) => item.executionState === "parked").length,
|
||||
done: items.filter((item) => item.executionState === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
function compareHeartbeatItems(a: HeartbeatItem, b: HeartbeatItem): number {
|
||||
const rankA = STATE_RANK[a.executionState];
|
||||
const rankB = STATE_RANK[b.executionState];
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
|
||||
if (a.owner !== b.owner) return a.owner.localeCompare(b.owner);
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function deriveRigName(sharedDocsRoot: string, queueFile: string): string {
|
||||
const rel = nodePath.relative(nodePath.join(sharedDocsRoot, "rigs"), queueFile);
|
||||
return rel.split(nodePath.sep)[0] ?? "";
|
||||
}
|
||||
|
||||
function deriveOwner(queueFile: string): string {
|
||||
const parts = queueFile.split(nodePath.sep);
|
||||
const stateIndex = parts.lastIndexOf("state");
|
||||
if (stateIndex < 0 || stateIndex + 2 >= parts.length) return nodePath.basename(queueFile, ".queue.md");
|
||||
const pod = parts[stateIndex + 1]!;
|
||||
const member = nodePath.basename(parts[stateIndex + 2]!, ".queue.md");
|
||||
return `${pod}.${member}`;
|
||||
}
|
||||
|
||||
function deriveSessionName(owner: string, rig: string): string {
|
||||
const [pod, member] = owner.split(".", 2);
|
||||
return pod && member ? `${pod}-${member}@${rig}` : `${owner}@${rig}`;
|
||||
}
|
||||
|
||||
function cleanNullable(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" || trimmed.toLowerCase() === "null" ? null : trimmed;
|
||||
}
|
||||
|
||||
function readPositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function formatAge(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3_600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h${Math.floor((seconds % 3_600) / 60)}m`;
|
||||
return `${Math.floor(seconds / 86_400)}d${Math.floor((seconds % 86_400) / 3_600)}h`;
|
||||
}
|
||||
|
||||
function printHumanHeartbeat(result: HeartbeatResult): void {
|
||||
const title = result.rigFilter
|
||||
? `WORKFLOW EXECUTION HEARTBEAT - ${result.rigFilter}`
|
||||
: "WORKFLOW EXECUTION HEARTBEAT - all rigs";
|
||||
console.log(title);
|
||||
console.log(
|
||||
`items: ${result.summary.total} | proven-active: ${result.summary.provenActive} | checked-out: ${result.summary.checkedOut} | stalled: ${result.summary.stalled} | unproven: ${result.summary.unproven} | blocked: ${result.summary.blocked} | parked: ${result.summary.parked} | done: ${result.summary.done}`,
|
||||
);
|
||||
if (result.items.length === 0) {
|
||||
console.log("No workflow queue items found.");
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(`${"execution".padEnd(15)} ${"queue".padEnd(12)} ${"task".padEnd(32)} ${"owner".padEnd(28)} ${"proof".padEnd(12)} age`);
|
||||
for (const item of result.items) {
|
||||
const proof = item.lastProof ? `${formatAge(item.lastProof.ageSeconds)} ago` : "-";
|
||||
const age = item.checkoutAgeSeconds == null ? "-" : formatAge(item.checkoutAgeSeconds);
|
||||
console.log(
|
||||
`${item.executionState.padEnd(15)} ${item.queueState.padEnd(12)} ${item.id.slice(0, 31).padEnd(32)} ${(item.rig + "/" + item.owner).padEnd(28)} ${proof.padEnd(12)} ${age}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.nudgeResults) {
|
||||
console.log("");
|
||||
console.log(`Nudges: ${result.nudgeResults.length}`);
|
||||
for (const nudge of result.nudgeResults) {
|
||||
console.log(` ${nudge.session}: ${nudge.ok ? "sent" : "failed"} ${nudge.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSend(session: string, text: string): Promise<{ ok: boolean; message: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("rig", ["send", session, text], { timeout: 10_000 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
resolve({ ok: false, message: (stderr || error.message).trim() });
|
||||
} else {
|
||||
resolve({ ok: true, message: stdout.trim() || "sent" });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface ImportDeps extends StatusDeps {
|
||||
readFile: (path: string) => string;
|
||||
}
|
||||
|
||||
const LONG_RUNNING_IMPORT_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
const cmd = new Command("import").description("Import a rig spec from YAML");
|
||||
const getDeps = (): ImportDeps => depsOverride ?? {
|
||||
@@ -120,7 +122,13 @@ export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
}
|
||||
|
||||
if (opts.instantiate) {
|
||||
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import", yaml, "text/yaml", extraHeaders);
|
||||
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>(
|
||||
"/api/rigs/import",
|
||||
yaml,
|
||||
"text/yaml",
|
||||
extraHeaders,
|
||||
{ timeoutMs: LONG_RUNNING_IMPORT_TIMEOUT_MS },
|
||||
);
|
||||
if (res.status === 409 || res.status === 400) {
|
||||
const data = res.data as { ok: false; code: string; errors?: string[]; message?: string };
|
||||
const detail = data.errors?.join("\n ") ?? data.message ?? `status ${res.status}`;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function preflightCommand(depsOverride?: PreflightCommandDeps): Command {
|
||||
|
||||
const exec = depsOverride?.exec ?? (async (c: string) => {
|
||||
const { execSync } = await import("node:child_process");
|
||||
return execSync(c, { encoding: "utf-8" });
|
||||
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
||||
});
|
||||
|
||||
const preflight = new SystemPreflight({
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig project` — coordination primitive L2 (classifier) commands (PL-004 Phase B).
|
||||
*
|
||||
* Backed by `/api/projects`. Operates only via the daemon HTTP API.
|
||||
*
|
||||
* Per PRD § L2: classifier judgment stays with the agent; daemon enforces
|
||||
* the lease + idempotency + reclaim contract.
|
||||
*/
|
||||
|
||||
export interface ProjectDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: ProjectDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function projectCommand(depsOverride?: ProjectDeps): Command {
|
||||
const cmd = new Command("project").description(
|
||||
"Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim",
|
||||
);
|
||||
const getDeps = (): ProjectDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
// ---- Lease lifecycle ----
|
||||
|
||||
cmd
|
||||
.command("lease-acquire")
|
||||
.description("Acquire the active classifier lease for the caller")
|
||||
.requiredOption("--session <session>", "Classifier session name")
|
||||
.option(
|
||||
"--evaluate-deadness-first",
|
||||
"Before acquire, call evaluateDeadness to clear any stale TTL-expired or dead-holder lease (per PRD § L2 deadness-detection)",
|
||||
)
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session: string; evaluateDeadnessFirst?: boolean; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/lease/acquire", {
|
||||
classifierSession: opts.session,
|
||||
evaluateDeadnessFirst: opts.evaluateDeadnessFirst,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("lease-heartbeat")
|
||||
.description("Send a heartbeat for an active classifier lease (extends TTL)")
|
||||
.requiredOption("--lease-id <id>", "Lease ID")
|
||||
.requiredOption("--session <session>", "Classifier session name")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { leaseId: string; session: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/lease/heartbeat", {
|
||||
leaseId: opts.leaseId,
|
||||
classifierSession: opts.session,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("lease-show")
|
||||
.description("Show the currently-active classifier lease")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/projects/lease");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Operator-verb reclaim ----
|
||||
|
||||
cmd
|
||||
.command("reclaim-classifier")
|
||||
.description(
|
||||
"Operator-verb: reclaim the active classifier lease. Use --if-dead to refuse if holder is still alive.",
|
||||
)
|
||||
.requiredOption("--session <session>", "Session that will hold the new lease")
|
||||
.option("--if-dead", "Only reclaim if the current holder is reported dead by the liveness check")
|
||||
.option("--reason <text>", "Reclaim reason (free-form; recorded in classifier_leases.reclaim_reason)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session: string; ifDead?: boolean; reason?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/reclaim-classifier", {
|
||||
byClassifierSession: opts.session,
|
||||
ifDead: opts.ifDead,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Project a stream item ----
|
||||
|
||||
cmd
|
||||
.command("classify <streamItemId>")
|
||||
.description(
|
||||
"Project a stream item with classification fields (idempotent on stream_item_id; requires active lease)",
|
||||
)
|
||||
.requiredOption("--session <session>", "Classifier session name (must hold active lease)")
|
||||
.option("--type <type>", "Classification type (e.g., idea, bug, feature-request)")
|
||||
.option("--urgency <urgency>", "Classification urgency (e.g., normal, high, critical)")
|
||||
.option("--maturity <maturity>", "Classification maturity (e.g., concept, drafted, ratified)")
|
||||
.option("--confidence <confidence>", "Classification confidence (e.g., low, medium, high)")
|
||||
.option("--destination <destination>", "Classification destination (downstream slice/seat)")
|
||||
.option("--action <action>", "Action type (e.g., create, advance)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (streamItemId: string, opts: {
|
||||
session: string;
|
||||
type?: string;
|
||||
urgency?: string;
|
||||
maturity?: string;
|
||||
confidence?: string;
|
||||
destination?: string;
|
||||
action?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/project", {
|
||||
streamItemId,
|
||||
classifierSession: opts.session,
|
||||
classificationType: opts.type,
|
||||
classificationUrgency: opts.urgency,
|
||||
classificationMaturity: opts.maturity,
|
||||
classificationConfidence: opts.confidence,
|
||||
classificationDestination: opts.destination,
|
||||
action: opts.action,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- List + show ----
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List project classifications with filters")
|
||||
.option("--session <session>", "Filter by classifier session")
|
||||
.option("--destination <destination>", "Filter by classification destination")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session?: string; destination?: string; limit: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.session) params.set("classifierSession", opts.session);
|
||||
if (opts.destination) params.set("classificationDestination", opts.destination);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/projects/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <projectId>")
|
||||
.description("Show one project classification")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (projectId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/projects/${encodeURIComponent(projectId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
+508
-36
@@ -3,13 +3,19 @@ import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
|
||||
interface PsEntry {
|
||||
rigId: string;
|
||||
name: string;
|
||||
/** L3-followup: alias of `name`. Always populated and equal to `name`. */
|
||||
rigName?: string;
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
status: "running" | "partial" | "stopped";
|
||||
lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required";
|
||||
uptime: string | null;
|
||||
latestSnapshot: string | null;
|
||||
}
|
||||
@@ -24,41 +30,302 @@ interface NodeEntry {
|
||||
nodeKind: "agent" | "infrastructure";
|
||||
runtime: string | null;
|
||||
sessionStatus: string | null;
|
||||
startupStatus: "pending" | "ready" | "failed" | null;
|
||||
startupStatus: "pending" | "ready" | "attention_required" | "failed" | null;
|
||||
restoreOutcome: string;
|
||||
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
latestError: string | null;
|
||||
agentActivity?: {
|
||||
state: "running" | "needs_input" | "idle" | "unknown";
|
||||
reason: string;
|
||||
evidenceSource: string;
|
||||
sampledAt: string;
|
||||
evidence: string | null;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// L3-followup: human-output budgets bound default terminal output for hosts
|
||||
// with realistic agent counts. `--full` opts out. JSON output remains
|
||||
// unbounded by default for back-compat (Decision C hybrid).
|
||||
const HUMAN_RIG_BUDGET = 50;
|
||||
const HUMAN_NODE_BUDGET = 100;
|
||||
|
||||
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
|
||||
// clear error with the supported list (Guard Review Checklist: filter-parser
|
||||
// security).
|
||||
const ALLOWED_FILTER_KEYS = new Set(["status", "lifecycleState", "name-prefix", "name"]);
|
||||
|
||||
// C9a: --fields accepts a per-level allow-list. Unknown keys produce a clear
|
||||
// error with the supported list, mirroring the --filter rejection pattern.
|
||||
// Per-level because rig and node entries have different schemas (PsEntry
|
||||
// rig-level vs NodeEntry node-level).
|
||||
//
|
||||
// Source of truth: PsEntry / NodeEntry interfaces above. `name` exists at
|
||||
// rig-level only; `rigName` is the alias and exists at both levels (per the
|
||||
// closed `rig ps trust and scale-safety` slice at openrig 0a9fb43).
|
||||
const ALLOWED_RIG_FIELDS = new Set([
|
||||
"rigId",
|
||||
"name",
|
||||
"rigName",
|
||||
"nodeCount",
|
||||
"runningCount",
|
||||
"status",
|
||||
"lifecycleState",
|
||||
"uptime",
|
||||
"latestSnapshot",
|
||||
]);
|
||||
const ALLOWED_NODE_FIELDS = new Set([
|
||||
"rigId",
|
||||
"rigName",
|
||||
"logicalId",
|
||||
"podId",
|
||||
"podNamespace",
|
||||
"canonicalSessionName",
|
||||
"nodeKind",
|
||||
"runtime",
|
||||
"sessionStatus",
|
||||
"startupStatus",
|
||||
"restoreOutcome",
|
||||
"lifecycleState",
|
||||
"tmuxAttachCommand",
|
||||
"resumeCommand",
|
||||
"latestError",
|
||||
"agentActivity",
|
||||
]);
|
||||
|
||||
interface PsCliOptions {
|
||||
json?: boolean;
|
||||
nodes?: boolean;
|
||||
full?: boolean;
|
||||
limit?: string;
|
||||
fields?: string;
|
||||
summary?: boolean;
|
||||
filter?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface PsDeps extends StatusDeps {
|
||||
/**
|
||||
* Cross-host hooks. Both default to the production loaders/executors; tests
|
||||
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
|
||||
* is touched. Mirrors the SendDeps/CaptureDeps shape from the closed
|
||||
* cross-host-rig-commands slice (cdce3a6).
|
||||
*/
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
crossHostRun?: (
|
||||
host: Parameters<typeof runCrossHostCommand>[0],
|
||||
argv: readonly string[],
|
||||
opts?: RunCrossHostCommandOpts,
|
||||
) => ReturnType<typeof runCrossHostCommand>;
|
||||
}
|
||||
|
||||
interface ParsedFilter {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function parseFilter(filter: string): ParsedFilter | { error: string } {
|
||||
const idx = filter.indexOf("=");
|
||||
if (idx === -1) return { error: `--filter must be key=value; got: '${filter}'` };
|
||||
const key = filter.slice(0, idx);
|
||||
const value = filter.slice(idx + 1);
|
||||
if (!ALLOWED_FILTER_KEYS.has(key)) {
|
||||
return {
|
||||
error: `Unknown --filter key '${key}'. Supported: ${[...ALLOWED_FILTER_KEYS].sort().join(", ")}`,
|
||||
};
|
||||
}
|
||||
if (!value) {
|
||||
return { error: `--filter value is empty for key '${key}'` };
|
||||
}
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
// C9a: validate --fields against a per-level allow-list. Mirrors parseFilter's
|
||||
// shape: rejects with a sorted-supported-list error message including the
|
||||
// unknown key(s) quoted. The level-aware "Hint" fires when an operator types
|
||||
// `--fields name` against a node query (the most likely confusion case
|
||||
// preserved by the rigName/name aliasing at rig-level only).
|
||||
function parseFields(input: string, allowed: Set<string>, level: "rig" | "nodes"): string[] | { error: string } {
|
||||
const fields = input.split(",").map((f) => f.trim()).filter((f) => f.length > 0);
|
||||
if (fields.length === 0) {
|
||||
return { error: `--fields cannot be empty` };
|
||||
}
|
||||
const unknown = fields.filter((f) => !allowed.has(f));
|
||||
if (unknown.length > 0) {
|
||||
const sorted = [...allowed].sort().join(", ");
|
||||
const hint = level === "nodes" && unknown.includes("name")
|
||||
? ` Hint: 'name' is a rig-level field; use 'rigName' for node entries.`
|
||||
: "";
|
||||
const keyWord = unknown.length > 1 ? "keys" : "key";
|
||||
return {
|
||||
error: `Unknown --fields ${keyWord} ${unknown.map((u) => `'${u}'`).join(", ")}. Supported: ${sorted}.${hint}`,
|
||||
};
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
|
||||
return entries.filter((e) => {
|
||||
if (filter.key === "status") return e.status === filter.value;
|
||||
if (filter.key === "lifecycleState") return e.lifecycleState === filter.value;
|
||||
if (filter.key === "name-prefix") return (e.rigName ?? e.name).startsWith(filter.value);
|
||||
if (filter.key === "name") return (e.rigName ?? e.name) === filter.value;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[] {
|
||||
return entries.filter((n) => {
|
||||
// status maps to sessionStatus for nodes; lifecycleState applies directly.
|
||||
if (filter.key === "status") return n.sessionStatus === filter.value;
|
||||
if (filter.key === "lifecycleState") return n.lifecycleState === filter.value;
|
||||
if (filter.key === "name-prefix") return n.rigName.startsWith(filter.value);
|
||||
if (filter.key === "name") return n.rigName === filter.value;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function selectFields<T extends Record<string, unknown>>(entries: T[], fields: string[]): Array<Record<string, unknown>> {
|
||||
return entries.map((e) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) out[f] = e[f];
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeRigs(entries: PsEntry[]): {
|
||||
totalRigs: number;
|
||||
totalRunning: number;
|
||||
byLifecycle: Record<string, number>;
|
||||
byStatus: Record<string, number>;
|
||||
} {
|
||||
const byLifecycle: Record<string, number> = {};
|
||||
const byStatus: Record<string, number> = {};
|
||||
let totalRunning = 0;
|
||||
for (const e of entries) {
|
||||
const ls = e.lifecycleState ?? "unknown";
|
||||
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
if (e.status === "running") totalRunning++;
|
||||
}
|
||||
return { totalRigs: entries.length, totalRunning, byLifecycle, byStatus };
|
||||
}
|
||||
|
||||
function summarizeNodes(entries: NodeEntry[]): {
|
||||
totalNodes: number;
|
||||
byLifecycle: Record<string, number>;
|
||||
bySessionStatus: Record<string, number>;
|
||||
} {
|
||||
const byLifecycle: Record<string, number> = {};
|
||||
const bySessionStatus: Record<string, number> = {};
|
||||
for (const n of entries) {
|
||||
const ls = n.lifecycleState ?? "unknown";
|
||||
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
|
||||
const ss = n.sessionStatus ?? "unknown";
|
||||
bySessionStatus[ss] = (bySessionStatus[ss] ?? 0) + 1;
|
||||
}
|
||||
return { totalNodes: entries.length, byLifecycle, bySessionStatus };
|
||||
}
|
||||
|
||||
// Compact rig-level lifecycle codes for the rig table header (3-char fixed width).
|
||||
function abbrevRigLifecycle(state: PsEntry["lifecycleState"] | undefined): string {
|
||||
if (!state) return "—";
|
||||
if (state === "running") return "run";
|
||||
if (state === "recoverable") return "rec";
|
||||
if (state === "stopped") return "stp";
|
||||
if (state === "degraded") return "deg";
|
||||
if (state === "attention_required") return "att";
|
||||
return "—";
|
||||
}
|
||||
|
||||
// Compact per-node lifecycle codes for the nodes table.
|
||||
function abbrevNodeLifecycle(state: NodeEntry["lifecycleState"] | undefined): string {
|
||||
if (!state) return "—";
|
||||
if (state === "running") return "run";
|
||||
if (state === "recoverable") return "rec";
|
||||
if (state === "detached") return "det";
|
||||
if (state === "attention_required") return "att";
|
||||
return "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig ps` — list running rigs and optionally their nodes.
|
||||
* @param depsOverride - injectable deps for testing
|
||||
* @returns Commander command
|
||||
*
|
||||
* L3-followup adds CLI-side shaping flags (`--limit`, `--fields`, `--summary`,
|
||||
* `--filter`, `--full`) for context-window-safe output on large hosts. Default
|
||||
* `rig ps` (human) truncates to ~50 rigs with an explicit footer naming totals
|
||||
* and the `--full` opt-out. Default `rig ps --json` keeps the bare-array shape
|
||||
* for back-compat; the truncation/envelope shape only applies when at least
|
||||
* one of `--limit`/`--summary`/`--fields`/`--filter` is specified.
|
||||
*/
|
||||
export function psCommand(depsOverride?: StatusDeps): Command {
|
||||
export function psCommand(depsOverride?: PsDeps): Command {
|
||||
const cmd = new Command("ps")
|
||||
.description("List rigs and their status")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig ps Show all rigs with status
|
||||
rig ps --json JSON output for piping/parsing
|
||||
rig ps --nodes Show per-node detail for all rigs
|
||||
rig ps --nodes --json Node inventory as JSON array
|
||||
rig ps Show all rigs (human; truncated to ${HUMAN_RIG_BUDGET} with footer)
|
||||
rig ps --full Disable human truncation
|
||||
rig ps --json JSON output (bare array; back-compat)
|
||||
rig ps --json --limit 20 Bounded JSON envelope
|
||||
rig ps --json --summary Aggregate-only JSON (no per-rig entries)
|
||||
rig ps --json --fields rigName,status,lifecycleState
|
||||
Project JSON to named fields
|
||||
rig ps --filter lifecycleState=attention_required
|
||||
Show only rigs needing attention
|
||||
rig ps --filter status=running Show only running rigs
|
||||
rig ps --filter name-prefix=demo Filter by rig-name prefix
|
||||
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
|
||||
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
|
||||
rig ps --host vm-claude-test --nodes --json Run on a remote host via single-hop ssh
|
||||
|
||||
JSON output entries include both \`name\` and \`rigName\` (alias) for forward
|
||||
compatibility; agent code should prefer \`rigName\` (matches per-node JSON).
|
||||
|
||||
--filter accepts: status, lifecycleState, name-prefix, name. Other keys are rejected.
|
||||
|
||||
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
|
||||
status, lifecycleState, uptime, latestSnapshot.
|
||||
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
|
||||
podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus,
|
||||
startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
|
||||
resumeCommand, latestError, agentActivity. Other keys are rejected.
|
||||
\`name\` is rig-level only; for node entries use \`rigName\`. Nested-field
|
||||
projection (e.g. \`agentActivity.state\`) is not supported in this slice; pass
|
||||
\`agentActivity\` to project the whole object and read the nested value
|
||||
downstream.
|
||||
|
||||
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
|
||||
via single-hop ssh (CLI-side shell-out; daemon untouched). The remote rig's
|
||||
output is what counts and is surfaced verbatim on success; failure is
|
||||
distinguished into ssh-unreachable / permission-gate / remote-daemon-unreachable
|
||||
/ remote-command-failed.
|
||||
|
||||
Exit codes:
|
||||
0 Success
|
||||
1 Daemon not running
|
||||
1 Daemon not running, or invalid --filter / --limit / --fields
|
||||
2 Failed to fetch data from daemon`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
const getDepsF = (): PsDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--nodes", "Show per-node detail for all rigs")
|
||||
.action(async (opts: { json?: boolean; nodes?: boolean }) => {
|
||||
.option("--full", "Disable default human-output truncation")
|
||||
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
|
||||
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
|
||||
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
|
||||
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.action(async (opts: PsCliOptions) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
if (opts.host) {
|
||||
await runCrossHostPs(opts.host, opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
@@ -66,10 +333,47 @@ Exit codes:
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse filter once up front so unknown keys/malformed filters surface
|
||||
// as exit-1 errors before any HTTP call.
|
||||
let parsedFilter: ParsedFilter | null = null;
|
||||
if (opts.filter) {
|
||||
const result = parseFilter(opts.filter);
|
||||
if ("error" in result) {
|
||||
console.error(result.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
parsedFilter = result;
|
||||
}
|
||||
|
||||
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
|
||||
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
|
||||
console.error(`--limit must be a non-negative integer; got '${opts.limit}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// C9a: validate --fields against the per-level allow-list before any HTTP
|
||||
// call, mirroring the --filter validation above. Level is determined by
|
||||
// --nodes; both branches downstream use the same `fields` variable, so
|
||||
// one up-front validation covers both paths.
|
||||
let fields: string[] | null = null;
|
||||
if (opts.fields !== undefined) {
|
||||
const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig";
|
||||
const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS;
|
||||
const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel);
|
||||
if ("error" in fieldsResult) {
|
||||
console.error(fieldsResult.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
fields = fieldsResult;
|
||||
}
|
||||
const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
if (opts.nodes) {
|
||||
await handleNodes(client, opts.json ?? false);
|
||||
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,38 +385,95 @@ Exit codes:
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = res.data;
|
||||
const all = res.data;
|
||||
|
||||
// Apply CLI-side filter (Amendment A: prefer CLI shaping).
|
||||
const filtered = parsedFilter ? applyRigFilter(all, parsedFilter) : all;
|
||||
|
||||
// Summary mode short-circuits per-entry output.
|
||||
if (opts.summary) {
|
||||
const summary = summarizeRigs(filtered);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(summary));
|
||||
} else {
|
||||
console.log(`totalRigs: ${summary.totalRigs}`);
|
||||
console.log(`totalRunning: ${summary.totalRunning}`);
|
||||
console.log(`byStatus: ${JSON.stringify(summary.byStatus)}`);
|
||||
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply --limit on top of filter (CLI side; daemon stays bare-array).
|
||||
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
|
||||
const truncated = limit !== null && filtered.length > limit;
|
||||
|
||||
// Field projection runs last so requested fields apply to the limited set.
|
||||
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entries));
|
||||
if (useEnvelope) {
|
||||
// Envelope only on flag use; default JSON stays a bare array for compat.
|
||||
const envelope: Record<string, unknown> = {
|
||||
entries: projected,
|
||||
totalRigs: filtered.length,
|
||||
truncated,
|
||||
};
|
||||
if (truncated) envelope.hint = "rig ps --full --json";
|
||||
console.log(JSON.stringify(envelope));
|
||||
} else {
|
||||
console.log(JSON.stringify(projected));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log("No rigs");
|
||||
if (limited.length === 0) {
|
||||
if (parsedFilter) {
|
||||
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
|
||||
} else {
|
||||
console.log("No rigs");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Formatted table
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "UPTIME", "SNAPSHOT");
|
||||
// Human output: apply default truncation budget unless --full.
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
|
||||
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
|
||||
console.log(header);
|
||||
for (const e of entries) {
|
||||
for (const e of humanList as PsEntry[]) {
|
||||
console.log(padRigRow(
|
||||
e.name,
|
||||
e.rigName ?? e.name,
|
||||
String(e.nodeCount),
|
||||
String(e.runningCount),
|
||||
e.status,
|
||||
abbrevRigLifecycle(e.lifecycleState),
|
||||
e.uptime ?? "—",
|
||||
e.latestSnapshot ?? "—",
|
||||
));
|
||||
}
|
||||
if (humanTruncated) {
|
||||
const remaining = filtered.length - HUMAN_RIG_BUDGET;
|
||||
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`);
|
||||
console.log("Run 'rig ps --full' to see all, or 'rig ps --filter lifecycleState=attention_required' to narrow.");
|
||||
} else if (truncated) {
|
||||
const remaining = filtered.length - (limit ?? 0);
|
||||
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
|
||||
// Fetch rig list first
|
||||
async function handleNodes(
|
||||
client: DaemonClient,
|
||||
opts: PsCliOptions,
|
||||
parsedFilter: ParsedFilter | null,
|
||||
limit: number | null,
|
||||
fields: string[] | null,
|
||||
useEnvelope: boolean,
|
||||
): Promise<void> {
|
||||
const rigRes = await client.get<PsEntry[]>("/api/ps");
|
||||
if (rigRes.status >= 400) {
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
|
||||
@@ -124,25 +485,60 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
|
||||
for (const rig of rigRes.data) {
|
||||
const nodesRes = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`);
|
||||
if (nodesRes.status >= 400) {
|
||||
console.error(`Warning: failed to fetch nodes for rig "${rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
|
||||
console.error(`Warning: failed to fetch nodes for rig "${rig.rigName ?? rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
|
||||
continue;
|
||||
}
|
||||
allNodes.push(...nodesRes.data);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(allNodes));
|
||||
const filtered = parsedFilter ? applyNodeFilter(allNodes, parsedFilter) : allNodes;
|
||||
|
||||
if (opts.summary) {
|
||||
const summary = summarizeNodes(filtered);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(summary));
|
||||
} else {
|
||||
console.log(`totalNodes: ${summary.totalNodes}`);
|
||||
console.log(`bySessionStatus: ${JSON.stringify(summary.bySessionStatus)}`);
|
||||
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (allNodes.length === 0) {
|
||||
console.log("No nodes");
|
||||
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
|
||||
const limitTruncated = limit !== null && filtered.length > limit;
|
||||
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
|
||||
|
||||
if (opts.json) {
|
||||
if (useEnvelope) {
|
||||
const envelope: Record<string, unknown> = {
|
||||
entries: projected,
|
||||
totalNodes: filtered.length,
|
||||
truncated: limitTruncated,
|
||||
};
|
||||
if (limitTruncated) envelope.hint = "rig ps --nodes --full --json";
|
||||
console.log(JSON.stringify(envelope));
|
||||
} else {
|
||||
console.log(JSON.stringify(projected));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "RESTORE", "ERROR");
|
||||
if (limited.length === 0) {
|
||||
if (parsedFilter) {
|
||||
console.log(`No nodes match --filter ${parsedFilter.key}=${parsedFilter.value}`);
|
||||
} else {
|
||||
console.log("No nodes");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
|
||||
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "RESTORE", "ERROR");
|
||||
console.log(header);
|
||||
for (const n of allNodes) {
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const parts = n.logicalId.split(".");
|
||||
const pod = n.podNamespace ?? (parts.length > 1 ? parts[0]! : "—");
|
||||
const member = parts.length > 1 ? parts.slice(1).join(".") : n.logicalId;
|
||||
@@ -155,10 +551,28 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
|
||||
n.runtime ?? "—",
|
||||
n.sessionStatus ?? "—",
|
||||
n.startupStatus ?? "—",
|
||||
abbrevNodeLifecycle(n.lifecycleState),
|
||||
formatActivity(n.agentActivity),
|
||||
n.restoreOutcome,
|
||||
n.latestError ? truncate(n.latestError, 30) : "—",
|
||||
));
|
||||
}
|
||||
if (humanTruncated) {
|
||||
const remaining = filtered.length - HUMAN_NODE_BUDGET;
|
||||
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_NODE_BUDGET}).`);
|
||||
console.log("Run 'rig ps --nodes --full' to see all, or '--filter lifecycleState=attention_required' to narrow.");
|
||||
} else if (limitTruncated) {
|
||||
const remaining = filtered.length - (limit ?? 0);
|
||||
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatActivity(activity: NodeEntry["agentActivity"]): string {
|
||||
if (!activity) return "unknown";
|
||||
if (activity.state === "running") return "running";
|
||||
if (activity.state === "needs_input") return "needs_input";
|
||||
if (activity.state === "idle") return "idle";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
@@ -169,27 +583,85 @@ function fitCell(value: string, width: number): string {
|
||||
return truncate(value, width).padEnd(width);
|
||||
}
|
||||
|
||||
function padRigRow(rig: string, nodes: string, running: string, status: string, uptime: string, snapshot: string): string {
|
||||
function padRigRow(rig: string, nodes: string, running: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
|
||||
return [
|
||||
fitCell(rig, 24),
|
||||
fitCell(nodes, 7),
|
||||
fitCell(running, 9),
|
||||
fitCell(status, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(uptime, 11),
|
||||
snapshot,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, restore: string, error: string): string {
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, restore: string, error: string): string {
|
||||
return [
|
||||
fitCell(rig, 32),
|
||||
fitCell(pod, 12),
|
||||
fitCell(member, 16),
|
||||
fitCell(session, 40),
|
||||
fitCell(runtime, 14),
|
||||
fitCell(rig, 30),
|
||||
fitCell(pod, 10),
|
||||
fitCell(member, 14),
|
||||
fitCell(session, 34),
|
||||
fitCell(runtime, 12),
|
||||
fitCell(status, 10),
|
||||
fitCell(startup, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(activity, 12),
|
||||
fitCell(restore, 10),
|
||||
error,
|
||||
].join("");
|
||||
}
|
||||
|
||||
|
||||
async function runCrossHostPs(
|
||||
hostId: string,
|
||||
opts: PsCliOptions,
|
||||
deps: PsDeps,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const runner = deps.crossHostRun ?? runCrossHostCommand;
|
||||
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// Reconstruct argv. `rig ps` has no positional args; all flags propagate.
|
||||
const argv: string[] = ["rig", "ps"];
|
||||
if (opts.nodes) argv.push("--nodes");
|
||||
if (opts.full) argv.push("--full");
|
||||
if (opts.limit !== undefined) argv.push("--limit", opts.limit);
|
||||
if (opts.fields !== undefined) argv.push("--fields", opts.fields);
|
||||
if (opts.summary) argv.push("--summary");
|
||||
if (opts.filter !== undefined) argv.push("--filter", opts.filter);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
|
||||
if (opts.json) {
|
||||
if (result.ok) {
|
||||
// Verbatim remote stdout passthrough — the remote `rig ps --json` already
|
||||
// produced the correct JSON shape (bare array OR envelope per its own
|
||||
// shaping flags); we do NOT double-wrap.
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, false);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
|
||||
*
|
||||
* Backed by `/api/queue`. Operates only via the daemon HTTP API.
|
||||
* Does NOT touch the POC `rigx-queue-proto` filesystem state.
|
||||
*
|
||||
* Hot-potato strict-rejection is enforced at the daemon; `update --state done`
|
||||
* without `--closure-reason` returns exit 1 with structured error naming the
|
||||
* 6 valid closure reasons.
|
||||
*/
|
||||
|
||||
export interface QueueDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: QueueDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
|
||||
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
|
||||
const getDeps = (): QueueDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("create")
|
||||
.description("Create a new qitem")
|
||||
.requiredOption("--source <session>", "Source session")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.requiredOption("--body <text>", "Qitem body")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
expiresAt?: string;
|
||||
id?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
expiresAt: opts.expiresAt,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("claim <qitemId>")
|
||||
.description("Claim a qitem (pending → in-progress); computes closure_required_at from tier")
|
||||
.option("--destination <session>", "Destination session claiming the qitem (defaults to OPENRIG_SESSION_NAME)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { destination?: string; json?: boolean }) => {
|
||||
const destination = resolveCurrentSession(opts.destination, "destination");
|
||||
if (!destination) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {
|
||||
destinationSession: destination,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("unclaim <qitemId>")
|
||||
.description("Release a claimed qitem (in-progress → pending)")
|
||||
.option("--destination <session>", "Destination session releasing the qitem (defaults to OPENRIG_SESSION_NAME)")
|
||||
.option("--reason <text>", "Reason for unclaim", "manual")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { destination?: string; reason: string; json?: boolean }) => {
|
||||
const destination = resolveCurrentSession(opts.destination, "destination");
|
||||
if (!destination) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
|
||||
destinationSession: destination,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("update <qitemId>")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
|
||||
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
|
||||
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
|
||||
.option("--closure-reason <reason>", "Required for state=done")
|
||||
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
actor?: string;
|
||||
state: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const actor = resolveCurrentSession(opts.actor, "actor");
|
||||
if (!actor) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
actorSession: actor,
|
||||
state: opts.state,
|
||||
closureReason: opts.closureReason,
|
||||
closureTarget: opts.closureTarget,
|
||||
transitionNote: opts.note,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("handoff <qitemId>")
|
||||
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
|
||||
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
|
||||
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
|
||||
.option("--body <text>", "New qitem body (defaults to source body)")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
from?: string;
|
||||
to: string;
|
||||
body?: string;
|
||||
note?: string;
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const from = resolveCurrentSession(opts.from, "from");
|
||||
if (!from) return;
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
|
||||
fromSession: from,
|
||||
toSession: opts.to,
|
||||
body: opts.body,
|
||||
transitionNote: opts.note,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("handoff-and-complete <qitemId>")
|
||||
.description(
|
||||
"Atomic close (state=done, closure_reason=handed_off_to) + create new qitem owned by --to. Variant of handoff that fully terminates the source qitem."
|
||||
)
|
||||
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
|
||||
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
|
||||
.option("--body <text>", "New qitem body (defaults to source body)")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
from?: string;
|
||||
to: string;
|
||||
body?: string;
|
||||
note?: string;
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const from = resolveCurrentSession(opts.from, "from");
|
||||
if (!from) return;
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff-and-complete`, {
|
||||
fromSession: from,
|
||||
toSession: opts.to,
|
||||
body: opts.body,
|
||||
transitionNote: opts.note,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("whoami")
|
||||
.description("Show the caller's queue position from the daemon's perspective")
|
||||
.option("--session <session>", "Caller's session name (defaults to OPENRIG_SESSION_NAME)")
|
||||
.option("--recent-limit <n>", "How many recent active qitems to include", "25")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session?: string; recentLimit: string; json?: boolean }) => {
|
||||
const session = resolveCurrentSession(opts.session, "session");
|
||||
if (!session) return;
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams({
|
||||
session,
|
||||
recentLimit: opts.recentLimit,
|
||||
});
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/whoami?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("fallback <qitemId>")
|
||||
.description("Reroute a qitem to a fallback destination (e.g. unreachable seat)")
|
||||
.requiredOption("--destination <session>", "Fallback destination seat")
|
||||
.option("--reason <text>", "Reason for fallback", "manual")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { destination: string; reason: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/fallback`, {
|
||||
fallbackDestination: opts.destination,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <qitemId>")
|
||||
.description("Show one qitem")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("transitions <qitemId>")
|
||||
.description("Show the append-only transition log for a qitem")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List qitems with filters")
|
||||
.option("--destination <session>", "Filter by destination session")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--state <state>", "Filter by state (comma-separated for multiple)")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
destination?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
limit: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("overdue")
|
||||
.description("List in-progress qitems past their closure_required_at deadline")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/queue/overdue");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Inbox subcommands ----
|
||||
|
||||
cmd
|
||||
.command("inbox-drop <destinationSession>")
|
||||
.description("Drop a mailbox-style entry into a destination's inbox")
|
||||
.requiredOption("--sender <session>", "Sender session")
|
||||
.requiredOption("--body <text>", "Inbox body")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
|
||||
.option("--audit <pointer>", "Audit pointer reference")
|
||||
.option("--id <inboxId>", "Idempotent inbox_id")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (destinationSession: string, opts: {
|
||||
sender: string;
|
||||
body: string;
|
||||
tags?: string;
|
||||
urgency: string;
|
||||
audit?: string;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/queue/inbox/drop", {
|
||||
inboxId: opts.id,
|
||||
destinationSession,
|
||||
senderSession: opts.sender,
|
||||
body: opts.body,
|
||||
tags,
|
||||
urgency: opts.urgency,
|
||||
auditPointer: opts.audit,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("inbox-absorb <inboxId>")
|
||||
.description("Absorb a pending inbox entry into the receiver's main queue")
|
||||
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (inboxId: string, opts: { receiver: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/absorb`, {
|
||||
receiverSession: opts.receiver,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("inbox-deny <inboxId>")
|
||||
.description("Deny a pending inbox entry with a recorded reason")
|
||||
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
|
||||
.requiredOption("--reason <text>", "Reason for denial")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (inboxId: string, opts: { receiver: string; reason: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/deny`, {
|
||||
receiverSession: opts.receiver,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("inbox-pending <destinationSession>")
|
||||
.description("List pending inbox entries for a destination seat")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (destinationSession: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams({ destinationSession });
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/inbox/pending?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Outbox subcommands ----
|
||||
|
||||
cmd
|
||||
.command("outbox-record")
|
||||
.description("Record an outbound dispatch in the sender's outbox")
|
||||
.requiredOption("--sender <session>", "Sender session")
|
||||
.requiredOption("--destination <session>", "Destination session")
|
||||
.requiredOption("--body <text>", "Body")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
|
||||
.option("--audit <pointer>", "Audit pointer reference")
|
||||
.option("--id <outboxId>", "Idempotent outbox_id")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
sender: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
tags?: string;
|
||||
urgency: string;
|
||||
audit?: string;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/queue/outbox/record", {
|
||||
outboxId: opts.id,
|
||||
senderSession: opts.sender,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
tags,
|
||||
urgency: opts.urgency,
|
||||
auditPointer: opts.audit,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("outbox-list <senderSession>")
|
||||
.description("List outbox entries for a sender seat")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (senderSession: string, opts: { limit: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams({ senderSession, limit: opts.limit });
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/outbox/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface RestoreCheckDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
interface CheckEntry {
|
||||
check: string;
|
||||
status: "green" | "yellow" | "red";
|
||||
evidence: string;
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
interface RepairStep {
|
||||
step: number;
|
||||
command: string;
|
||||
rationale: string;
|
||||
safe: boolean;
|
||||
blocking: boolean;
|
||||
}
|
||||
|
||||
interface ReadinessAssertion {
|
||||
status: "ready" | "ready_with_caveats" | "not_ready" | "unknown";
|
||||
reason: string;
|
||||
blockingRigCount: number;
|
||||
caveatRigCount: number;
|
||||
unknownRigCount: number;
|
||||
}
|
||||
|
||||
interface ContinuityAssertion {
|
||||
status: "proven" | "not_proven" | "partial" | "not_applicable";
|
||||
evidence: string;
|
||||
provenCapabilities: string[];
|
||||
unprovenCapabilities: string[];
|
||||
}
|
||||
|
||||
interface RigRestoreRollup {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
status: "ready" | "ready_with_caveats" | "not_ready" | "unknown";
|
||||
verdict: "restorable" | "restorable_with_caveats" | "not_restorable" | "unknown";
|
||||
expectedNodes: number;
|
||||
runningReadyNodes: number;
|
||||
blockedNodes: number;
|
||||
caveatNodes: number;
|
||||
blockingChecks: CheckEntry[];
|
||||
caveatChecks: CheckEntry[];
|
||||
}
|
||||
|
||||
interface HostInfraAssertion {
|
||||
status: "not_inspected" | "not_declared" | "declared" | "unknown";
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
interface RecoveryAction {
|
||||
scope: "rig";
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
action: "restore_from_latest_snapshot";
|
||||
command: string;
|
||||
reason: string;
|
||||
safe: boolean;
|
||||
blocking: boolean;
|
||||
}
|
||||
|
||||
interface RecoveryIssue {
|
||||
scope: "host" | "rig";
|
||||
rigId?: string;
|
||||
rigName?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface RecoveryPlan {
|
||||
status: "not_needed" | "actionable" | "blocked" | "unknown";
|
||||
summary: string;
|
||||
actions: RecoveryAction[];
|
||||
blocked: RecoveryIssue[];
|
||||
unknown: RecoveryIssue[];
|
||||
}
|
||||
|
||||
interface RestoreCheckResult {
|
||||
verdict: "restorable" | "restorable_with_caveats" | "not_restorable" | "unknown";
|
||||
readiness: ReadinessAssertion;
|
||||
continuity: ContinuityAssertion;
|
||||
rigs: RigRestoreRollup[];
|
||||
hostInfra: HostInfraAssertion;
|
||||
recovery: RecoveryPlan;
|
||||
counts: { red: number; yellow: number; green: number };
|
||||
checks: CheckEntry[];
|
||||
repairPacket: RepairStep[] | null;
|
||||
}
|
||||
|
||||
const STATUS_SYMBOLS: Record<string, string> = {
|
||||
green: "✓",
|
||||
yellow: "⚠",
|
||||
red: "✗",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
green: "",
|
||||
yellow: "",
|
||||
red: "",
|
||||
};
|
||||
|
||||
export function restoreCheckCommand(depsOverride?: RestoreCheckDeps): Command {
|
||||
const cmd = new Command("restore-check")
|
||||
.description("Check restore readiness across running rigs")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig restore-check Check all rigs
|
||||
rig restore-check --rig openrig-pm Check one rig
|
||||
rig restore-check --no-queue Skip queue file checks
|
||||
rig restore-check --no-hooks Skip hook checks
|
||||
rig restore-check --json JSON output for agents
|
||||
|
||||
Exit codes:
|
||||
0 Restorable (or restorable with caveats)
|
||||
1 Not restorable (red blockers found)
|
||||
2 Unknown / probe error`);
|
||||
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig <name>", "Check one rig only")
|
||||
.option("--no-queue", "Skip queue file checks")
|
||||
.option("--no-hooks", "Skip hook checks")
|
||||
.action(async (opts: { json?: boolean; rig?: string; queue?: boolean; hooks?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
// Daemon down — produce a structured not_restorable result
|
||||
const result = localRestoreResult({
|
||||
verdict: "not_restorable",
|
||||
checks: [{
|
||||
check: "daemon.reachable",
|
||||
status: "red",
|
||||
evidence: "Daemon is not running",
|
||||
remediation: "Start the daemon with: rig daemon start",
|
||||
}],
|
||||
repairPacket: [{
|
||||
step: 1,
|
||||
command: "Start the daemon with: rig daemon start",
|
||||
rationale: "Daemon is not running",
|
||||
safe: false, // mutating: starts a daemon process
|
||||
blocking: true,
|
||||
}],
|
||||
recovery: {
|
||||
status: "blocked",
|
||||
summary: "No exact rig recovery action is known until the daemon is running.",
|
||||
actions: [],
|
||||
blocked: [{
|
||||
scope: "host",
|
||||
reason: "Daemon is not running; rig state could not be inspected for recovery planning.",
|
||||
}],
|
||||
unknown: [],
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.rig) params.set("rig", opts.rig);
|
||||
if (opts.queue === false) params.set("noQueue", "true");
|
||||
if (opts.hooks === false) params.set("noHooks", "true");
|
||||
const query = params.toString();
|
||||
const path = `/api/restore-check${query ? `?${query}` : ""}`;
|
||||
|
||||
const response = await client.get<RestoreCheckResult>(path);
|
||||
|
||||
if (response.status >= 500) {
|
||||
const result = localRestoreResult({
|
||||
verdict: "unknown",
|
||||
checks: [{
|
||||
check: "probe.error",
|
||||
status: "red",
|
||||
evidence: `Daemon returned HTTP ${response.status}`,
|
||||
remediation: "Check daemon logs with: rig daemon logs",
|
||||
}],
|
||||
repairPacket: [{
|
||||
step: 1,
|
||||
command: "Check daemon logs with: rig daemon logs",
|
||||
rationale: `Daemon returned HTTP ${response.status}`,
|
||||
safe: true,
|
||||
blocking: true,
|
||||
}],
|
||||
recovery: {
|
||||
status: "unknown",
|
||||
summary: `Recovery status could not be inspected because daemon returned HTTP ${response.status}.`,
|
||||
actions: [],
|
||||
blocked: [],
|
||||
unknown: [{
|
||||
scope: "host",
|
||||
reason: `Daemon returned HTTP ${response.status}`,
|
||||
}],
|
||||
},
|
||||
});
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
|
||||
// Exit codes: 0 for restorable/restorable_with_caveats; 1 for not_restorable; 2 for unknown
|
||||
if (result.verdict === "not_restorable") {
|
||||
process.exitCode = 1;
|
||||
} else if (result.verdict === "unknown") {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error("Fix: check daemon status with: rig daemon status");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function printHuman(result: RestoreCheckResult): void {
|
||||
const verdictLabel = result.verdict.replace(/_/g, " ").toUpperCase();
|
||||
console.log(`RESTORE CHECK — ${verdictLabel}`);
|
||||
console.log(`${result.counts.green} green | ${result.counts.yellow} yellow | ${result.counts.red} red`);
|
||||
const readinessLabel = result.readiness.status.replace(/_/g, " ");
|
||||
console.log(`READINESS: ${readinessLabel} (${result.readiness.reason})`);
|
||||
console.log(`CONTINUITY: ${result.continuity.status.replace(/_/g, " ")}`);
|
||||
if (result.hostInfra) {
|
||||
console.log(`Host bootstrap/autostart: ${result.hostInfra.status} — ${result.hostInfra.evidence}`);
|
||||
}
|
||||
console.log(`RECOVERY: ${result.recovery.status.replace(/_/g, " ").toUpperCase()}`);
|
||||
console.log(result.recovery.summary);
|
||||
for (const action of result.recovery.actions) {
|
||||
console.log(` Action: ${action.command}`);
|
||||
}
|
||||
for (const issue of result.recovery.blocked) {
|
||||
console.log(` Blocked: ${issue.reason}`);
|
||||
}
|
||||
for (const issue of result.recovery.unknown) {
|
||||
console.log(` Unknown: ${issue.reason}`);
|
||||
}
|
||||
if (result.rigs && result.rigs.length > 0) {
|
||||
console.log();
|
||||
console.log("Per-rig summary:");
|
||||
console.log(`${"rig".padEnd(24)} ${"status".padEnd(16)} ${"ready".padEnd(9)} ${"blocked".padEnd(8)} caveats`);
|
||||
for (const rig of result.rigs) {
|
||||
console.log(
|
||||
`${rig.rigName.slice(0, 24).padEnd(24)} ${rig.status.padEnd(16)} ` +
|
||||
`${`${rig.runningReadyNodes}/${rig.expectedNodes}`.padEnd(9)} ${String(rig.blockedNodes).padEnd(8)} ${rig.caveatNodes}`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
for (const check of result.checks) {
|
||||
const sym = STATUS_SYMBOLS[check.status] ?? "?";
|
||||
console.log(` ${sym} ${check.check}: ${check.evidence}`);
|
||||
if (check.remediation) {
|
||||
console.log(` Fix: ${check.remediation}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.repairPacket && result.repairPacket.length > 0) {
|
||||
const blockers = result.repairPacket.filter((s) => s.blocking).length;
|
||||
const caveats = result.repairPacket.filter((s) => !s.blocking).length;
|
||||
console.log();
|
||||
console.log(`Repair steps: ${result.repairPacket.length} (${blockers} blocking, ${caveats} caveats)`);
|
||||
if (blockers > 0) {
|
||||
console.log("Resolve blocking steps before restoring.");
|
||||
}
|
||||
} else if (result.counts.red > 0) {
|
||||
console.log();
|
||||
console.log("Blockers found. Resolve red items before restoring.");
|
||||
} else if (result.counts.yellow > 0) {
|
||||
console.log();
|
||||
console.log("Restorable with caveats. Yellow items are non-blocking but worth reviewing.");
|
||||
}
|
||||
}
|
||||
|
||||
function localRestoreResult(input: {
|
||||
verdict: RestoreCheckResult["verdict"];
|
||||
checks: CheckEntry[];
|
||||
repairPacket: RepairStep[] | null;
|
||||
recovery: RecoveryPlan;
|
||||
}): RestoreCheckResult {
|
||||
const red = input.checks.filter((c) => c.status === "red").length;
|
||||
const yellow = input.checks.filter((c) => c.status === "yellow").length;
|
||||
const green = input.checks.filter((c) => c.status === "green").length;
|
||||
const unknown = input.verdict === "unknown";
|
||||
|
||||
return {
|
||||
verdict: input.verdict,
|
||||
readiness: {
|
||||
status: unknown ? "unknown" : "not_ready",
|
||||
reason: unknown ? "unknown_probe_state" : "blockers_present",
|
||||
blockingRigCount: 0,
|
||||
caveatRigCount: 0,
|
||||
unknownRigCount: 0,
|
||||
},
|
||||
continuity: {
|
||||
status: "not_proven",
|
||||
evidence: "Strict same-session/provider-context resume is not verified by restore-check v1.",
|
||||
provenCapabilities: [],
|
||||
unprovenCapabilities: ["provider_session_resume", "context_window_preservation", "interrupted_work_functional_resume"],
|
||||
},
|
||||
rigs: [],
|
||||
hostInfra: {
|
||||
status: "unknown",
|
||||
evidence: "Host bootstrap/autostart source could not be inspected because restore-check did not receive daemon route evidence",
|
||||
},
|
||||
counts: { red, yellow, green },
|
||||
checks: input.checks,
|
||||
repairPacket: input.repairPacket,
|
||||
recovery: input.recovery,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
// restore-packet.ts — `rig restore-packet {write,read,validate}` CLI command.
|
||||
//
|
||||
// M2a: command shape + mutual-exclusion + read/validate stubs.
|
||||
// M2b: source-adapter wiring (codex-jsonl-parser, claude-transcript-parser,
|
||||
// runtime-detect, redaction, omitted-records).
|
||||
// M2c-CLI: write action full impl using packet-writer.ts; --source-jsonl
|
||||
// + --source-session adapter wiring; CLI flag defaults for the
|
||||
// ~11 contract-required fields not derivable from the parser.
|
||||
// M2c-Daemon: full-read transcript route + auth + redaction + tests.
|
||||
// M3: real read + validate implementations replacing the stubs.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { resolve as resolvePath, join as joinPath } from "node:path";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { detectRuntime } from "../restore-packet/runtime-detect.js";
|
||||
import { parseCodexJsonl } from "../restore-packet/codex-jsonl-parser.js";
|
||||
import { parseClaudeTranscript } from "../restore-packet/claude-transcript-parser.js";
|
||||
import { writePacket, type WritePacketOptions } from "../restore-packet/packet-writer.js";
|
||||
import {
|
||||
validateRestoreSummary,
|
||||
type ValidationError,
|
||||
} from "../restore-packet/schema-validator.js";
|
||||
import type { SourceRuntime, StructuredTranscript } from "../restore-packet/types.js";
|
||||
|
||||
// Per M1 contract § 1: 4 required packet files. Used by validate's
|
||||
// packet-shape check and by read for transcript-presence detection.
|
||||
const REQUIRED_PACKET_FILES = [
|
||||
"restore-instructions.md",
|
||||
"transcript-latest.md",
|
||||
"touched-files.md",
|
||||
"restore-summary.json",
|
||||
] as const;
|
||||
|
||||
export interface RestorePacketDeps {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
clientFactory?: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
interface WriteOptions {
|
||||
sourceSession?: string;
|
||||
sourceJsonl?: string;
|
||||
sourceRuntime?: string;
|
||||
/**
|
||||
* R2: explicit operator override for source_session_id. Wins over parsed
|
||||
* session_meta and over the --source-session arg. Required when JSONL
|
||||
* has no session_meta record.
|
||||
*/
|
||||
sourceSessionIdOverride?: string;
|
||||
/**
|
||||
* R2: explicit operator override for source_rig. Wins over <seat>@<rig>
|
||||
* derivation. Required when the canonical source_session_id is bare
|
||||
* (no @) and the operator has not supplied --source-session-id-override
|
||||
* with @ shape.
|
||||
*/
|
||||
sourceRigOverride?: string;
|
||||
target: string;
|
||||
targetRig?: string;
|
||||
targetRuntime?: string;
|
||||
targetWorkspaceRoot?: string;
|
||||
defaultTargetRepo?: string;
|
||||
rolePointer?: string;
|
||||
currentWorkSummary?: string;
|
||||
nextOwner?: string;
|
||||
caveat?: string[];
|
||||
authorityBoundaries?: string;
|
||||
sourceTrustRanking?: string;
|
||||
generatorVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* R2: derive canonical (source_session_id, source_rig) per honest-fallback
|
||||
* policy. Override flags > parsed session_meta (jsonl) > supplied --source-session.
|
||||
*
|
||||
* Returns either the derived pair (with no exception) OR throws an Error
|
||||
* containing the explicit operator-facing guidance message. The action
|
||||
* caller catches and routes to reportFailure().
|
||||
*/
|
||||
function deriveProvenance(
|
||||
opts: WriteOptions,
|
||||
parsedSessionId: string | null,
|
||||
hasJsonl: boolean,
|
||||
): { sourceSessionId: string; sourceRig: string } {
|
||||
// Step 1: source_session_id — override > parsed > --source-session arg.
|
||||
let sourceSessionId: string;
|
||||
if (typeof opts.sourceSessionIdOverride === "string" && opts.sourceSessionIdOverride.length > 0) {
|
||||
sourceSessionId = opts.sourceSessionIdOverride;
|
||||
} else if (hasJsonl) {
|
||||
if (!parsedSessionId) {
|
||||
throw new Error(
|
||||
"no session_meta record in source JSONL; supply --source-session-id-override <id> AND --source-rig-override <rig> to set provenance explicitly. Silent fallback to file-path-as-session-id is not allowed (M2c-CLI R2 honest-provenance policy).",
|
||||
);
|
||||
}
|
||||
sourceSessionId = parsedSessionId;
|
||||
} else {
|
||||
// hasSession path: operator-named session is authoritative.
|
||||
sourceSessionId = opts.sourceSession!;
|
||||
}
|
||||
|
||||
// Step 2: source_rig — override > <seat>@<rig> split.
|
||||
let sourceRig: string;
|
||||
if (typeof opts.sourceRigOverride === "string" && opts.sourceRigOverride.length > 0) {
|
||||
sourceRig = opts.sourceRigOverride;
|
||||
} else {
|
||||
const m = /^[^@]+@([^@]+)$/.exec(sourceSessionId);
|
||||
const captured = m?.[1];
|
||||
if (!captured) {
|
||||
throw new Error(
|
||||
`could not derive source_rig from session id '${sourceSessionId}' (does not match <seat>@<rig> shape); supply --source-rig-override <rig> explicitly. Silent fallback to source_rig:"unknown" is not allowed (M2c-CLI R2 honest-provenance policy).`,
|
||||
);
|
||||
}
|
||||
sourceRig = captured;
|
||||
}
|
||||
|
||||
return { sourceSessionId, sourceRig };
|
||||
}
|
||||
|
||||
function reportFailure(message: string): void {
|
||||
console.error(`rig restore-packet write: ${message}`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
function isValidRuntime(value: string | undefined): value is SourceRuntime {
|
||||
return value === "codex" || value === "claude-code";
|
||||
}
|
||||
|
||||
async function fetchSourceTranscriptViaDaemon(
|
||||
session: string,
|
||||
deps: RestorePacketDeps | undefined,
|
||||
): Promise<{ content: string; sourceCwd: string | null }> {
|
||||
const lifecycleDeps = deps?.lifecycleDeps ?? realDeps();
|
||||
const status = await getDaemonStatus(lifecycleDeps);
|
||||
if (status.state !== "running" || typeof status.port !== "number") {
|
||||
throw new Error("daemon not running; start it with: rig daemon start");
|
||||
}
|
||||
const url = getDaemonUrl(status);
|
||||
const client = (deps?.clientFactory ?? ((u: string) => new DaemonClient(u)))(url);
|
||||
// M2c-Daemon will define this route. M2c-CLI calls it; M2c-CLI tests
|
||||
// mock the daemon (compact-plan.test.ts:166-176 pattern). The path is
|
||||
// a draft — M2c-Daemon may pick a different naming convention; if so,
|
||||
// M2c-Daemon will land that change in restore-packet.ts the same commit
|
||||
// that adds the daemon route.
|
||||
const path = `/api/transcripts/${encodeURIComponent(session)}/full`;
|
||||
const response = await client.get<{ content: string; cwd?: string | null }>(path);
|
||||
if (response.status >= 400 || !response.data) {
|
||||
throw new Error(
|
||||
`daemon transcript fetch failed: HTTP ${response.status}: ${
|
||||
typeof response.data === "object" && response.data !== null
|
||||
? JSON.stringify(response.data)
|
||||
: "(no body)"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
content: response.data.content ?? "",
|
||||
sourceCwd: response.data.cwd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWritePacketOptions(
|
||||
opts: WriteOptions,
|
||||
structured: StructuredTranscript,
|
||||
sourceRuntime: SourceRuntime,
|
||||
sourceCwdFallback: string,
|
||||
sourceSessionId: string,
|
||||
sourceRig: string,
|
||||
): WritePacketOptions {
|
||||
const trustRankingDefault = "rig_whoami,bounded_latest_transcript";
|
||||
const trustRankingRaw = opts.sourceTrustRanking ?? trustRankingDefault;
|
||||
const trustRanking = trustRankingRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
return {
|
||||
targetDir: resolvePath(opts.target),
|
||||
structured,
|
||||
sourceRuntime,
|
||||
targetRig: opts.targetRig ?? "openrig-velocity",
|
||||
targetRuntime: isValidRuntime(opts.targetRuntime) ? opts.targetRuntime : "claude-code",
|
||||
targetWorkspaceRoot: opts.targetWorkspaceRoot ?? structured.sessionMeta?.cwd ?? sourceCwdFallback,
|
||||
defaultTargetRepo: opts.defaultTargetRepo ?? null,
|
||||
rolePointer: opts.rolePointer ?? `rigs/${opts.targetRig ?? "openrig-velocity"}/state/velocity/role.md`,
|
||||
currentWorkSummary: opts.currentWorkSummary ?? "Cross-runtime restore packet generated by rig restore-packet write.",
|
||||
nextOwner: opts.nextOwner ?? "self",
|
||||
caveats: opts.caveat ?? [],
|
||||
authorityBoundaries: opts.authorityBoundaries ?? "Restored seat authority per source session role.",
|
||||
sourceTrustRanking: trustRanking,
|
||||
sourceSessionId,
|
||||
sourceRig,
|
||||
sourceCwd: structured.sessionMeta?.cwd ?? sourceCwdFallback,
|
||||
generatorVersion: opts.generatorVersion ?? "[email protected]",
|
||||
includeFullTranscript: structured.messages.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function restorePacketCommand(depsOverride?: RestorePacketDeps): Command {
|
||||
const cmd = new Command("restore-packet")
|
||||
.description(
|
||||
"Generate, read, and validate cross-runtime restore packets per the v0 standard",
|
||||
);
|
||||
|
||||
cmd.command("write")
|
||||
.description(
|
||||
"Generate a restore packet from a source session or JSONL file",
|
||||
)
|
||||
.option("--source-session <session>", "Source session name (daemon-backed)")
|
||||
.option(
|
||||
"--source-jsonl <path>",
|
||||
"Source Codex/Claude JSONL transcript file (direct)",
|
||||
)
|
||||
.option(
|
||||
"--source-runtime <runtime>",
|
||||
"Force source runtime (claude-code | codex); auto-detected if omitted",
|
||||
)
|
||||
.requiredOption(
|
||||
"--target <dir>",
|
||||
"Target packet directory (must not exist; created atomically)",
|
||||
)
|
||||
.option("--target-rig <rig>", "Target rig name for the restored seat")
|
||||
.option(
|
||||
"--target-runtime <runtime>",
|
||||
"Target runtime (claude-code | codex)",
|
||||
)
|
||||
.option(
|
||||
"--target-workspace-root <path>",
|
||||
"Target workspace root absolute path",
|
||||
)
|
||||
.option(
|
||||
"--default-target-repo <path>",
|
||||
"Default target repo absolute path or null",
|
||||
)
|
||||
.option(
|
||||
"--role-pointer <path>",
|
||||
"Role guidance pointer path or URI for the restored seat",
|
||||
)
|
||||
.option(
|
||||
"--current-work-summary <text>",
|
||||
"One-paragraph summary of the source seat's current work",
|
||||
)
|
||||
.option(
|
||||
"--next-owner <name>",
|
||||
'Who the restored seat hands off to next (default: "self")',
|
||||
)
|
||||
.option(
|
||||
"--caveat <text>",
|
||||
"Caveat to include in the packet (repeatable)",
|
||||
(value: string, prev: string[] = []) => prev.concat([value]),
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--authority-boundaries <text>",
|
||||
"Explicit statement of restored-seat authority",
|
||||
)
|
||||
.option(
|
||||
"--source-trust-ranking <csv>",
|
||||
"Comma-separated source-trust ranking; default: rig_whoami,bounded_latest_transcript",
|
||||
)
|
||||
.option(
|
||||
"--generator-version <version>",
|
||||
'Generator version identifier (default: "[email protected]")',
|
||||
)
|
||||
.option(
|
||||
"--source-session-id-override <id>",
|
||||
"Operator override for source_session_id (R2; required when JSONL has no session_meta record)",
|
||||
)
|
||||
.option(
|
||||
"--source-rig-override <rig>",
|
||||
"Operator override for source_rig (R2; required when canonical session id is bare with no <seat>@<rig> shape)",
|
||||
)
|
||||
.action(async (opts: WriteOptions) => {
|
||||
const hasSession = typeof opts.sourceSession === "string" && opts.sourceSession.length > 0;
|
||||
const hasJsonl = typeof opts.sourceJsonl === "string" && opts.sourceJsonl.length > 0;
|
||||
if (hasSession && hasJsonl) {
|
||||
reportFailure(
|
||||
"--source-session and --source-jsonl are mutually exclusive; supply exactly one.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hasSession && !hasJsonl) {
|
||||
reportFailure(
|
||||
"exactly one of --source-session or --source-jsonl is required.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let content: string;
|
||||
let sourceCwdFallback: string;
|
||||
if (hasJsonl) {
|
||||
const jsonlPath = resolvePath(opts.sourceJsonl!);
|
||||
content = readFileSync(jsonlPath, "utf-8");
|
||||
sourceCwdFallback = "/";
|
||||
} else {
|
||||
const session = opts.sourceSession!;
|
||||
const fetched = await fetchSourceTranscriptViaDaemon(session, depsOverride);
|
||||
content = fetched.content;
|
||||
sourceCwdFallback = fetched.sourceCwd ?? "/";
|
||||
}
|
||||
|
||||
let runtime: SourceRuntime | null = null;
|
||||
if (isValidRuntime(opts.sourceRuntime)) {
|
||||
runtime = opts.sourceRuntime;
|
||||
} else if (typeof opts.sourceRuntime === "string" && opts.sourceRuntime.length > 0) {
|
||||
reportFailure(
|
||||
`--source-runtime must be 'codex' or 'claude-code'; got '${opts.sourceRuntime}'.`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
runtime = detectRuntime(content);
|
||||
if (runtime === null) {
|
||||
reportFailure(
|
||||
"could not auto-detect source runtime; pass --source-runtime <claude-code|codex> explicitly.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const structured = runtime === "codex"
|
||||
? parseCodexJsonl(content)
|
||||
: parseClaudeTranscript(content);
|
||||
|
||||
// R2: derive (source_session_id, source_rig) honestly. Override
|
||||
// flags > parsed session_meta (jsonl) > supplied --source-session
|
||||
// arg. Throws an Error with explicit operator guidance when the
|
||||
// honest-provenance policy can't be satisfied.
|
||||
let provenance: { sourceSessionId: string; sourceRig: string };
|
||||
try {
|
||||
provenance = deriveProvenance(opts, structured.sessionMeta?.sessionId ?? null, hasJsonl);
|
||||
} catch (err) {
|
||||
reportFailure((err as Error).message);
|
||||
return;
|
||||
}
|
||||
|
||||
const writeOpts = buildWritePacketOptions(
|
||||
opts,
|
||||
structured,
|
||||
runtime,
|
||||
sourceCwdFallback,
|
||||
provenance.sourceSessionId,
|
||||
provenance.sourceRig,
|
||||
);
|
||||
const result = await writePacket(writeOpts);
|
||||
|
||||
// Per Quality Lesson v9: log only metadata, NOT transcript content.
|
||||
console.log(`packet: ${result.targetDir}`);
|
||||
console.log(`files: ${result.files.length}`);
|
||||
for (const f of result.files) {
|
||||
console.log(` - ${f}`);
|
||||
}
|
||||
console.log(`messages: ${structured.messageCount}`);
|
||||
console.log(`omitted-classes: ${Object.entries(structured.omittedCounts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(",") || "(none)"}`);
|
||||
} catch (err) {
|
||||
reportFailure((err as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("read")
|
||||
.description("Render a restore packet contents (human or --json)")
|
||||
.argument("<packet-dir>", "Packet directory path")
|
||||
.option("--json", "Emit machine-readable JSON output")
|
||||
.action(async (packetDir: string, opts: { json?: boolean }) => {
|
||||
const dir = resolvePath(packetDir);
|
||||
const summaryPath = joinPath(dir, "restore-summary.json");
|
||||
if (!existsSync(summaryPath)) {
|
||||
console.error(
|
||||
`rig restore-packet read: restore-summary.json not found at ${summaryPath}.`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
let summary: Record<string, unknown>;
|
||||
try {
|
||||
summary = JSON.parse(readFileSync(summaryPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`rig restore-packet read: restore-summary.json is not valid JSON: ${(err as Error).message}.`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
// Per IMPL § M3: --json emits raw restore-summary.json content
|
||||
// verbatim; round-trippable.
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Human output: restore-instructions body + summary metadata + transcript digest.
|
||||
const instructionsPath = joinPath(dir, "restore-instructions.md");
|
||||
if (existsSync(instructionsPath)) {
|
||||
console.log(readFileSync(instructionsPath, "utf-8"));
|
||||
} else {
|
||||
console.log("(restore-instructions.md absent in packet directory)");
|
||||
}
|
||||
console.log("--- Summary metadata ---");
|
||||
const metadataKeys = [
|
||||
"source_session_id",
|
||||
"source_rig",
|
||||
"source_cwd",
|
||||
"source_runtime",
|
||||
"target_rig",
|
||||
"target_runtime",
|
||||
"generator_version",
|
||||
];
|
||||
for (const k of metadataKeys) {
|
||||
const v = summary[k];
|
||||
console.log(`${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
|
||||
}
|
||||
const blt = summary.bounded_latest_transcript as { bound?: number; message_count?: number } | undefined;
|
||||
if (blt) {
|
||||
console.log(`bounded_latest_transcript.bound: ${blt.bound}`);
|
||||
console.log(`bounded_latest_transcript.message_count: ${blt.message_count}`);
|
||||
}
|
||||
const omittedClasses = summary.omitted_classes;
|
||||
console.log(`omitted_classes: ${Array.isArray(omittedClasses) ? omittedClasses.join(",") || "(none)" : "(unknown)"}`);
|
||||
|
||||
// Transcript digest line (per IMPL § M3 + contract § 1 parity rule).
|
||||
const transcriptPath = joinPath(dir, "transcript.md");
|
||||
const hasFullTranscriptKey = Object.prototype.hasOwnProperty.call(summary, "full_transcript");
|
||||
if (existsSync(transcriptPath)) {
|
||||
const sz = statSync(transcriptPath).size;
|
||||
console.log(`transcript.md: ${sz} bytes`);
|
||||
if (!hasFullTranscriptKey) {
|
||||
console.log("(parity warning: transcript.md present but full_transcript summary key absent)");
|
||||
}
|
||||
} else {
|
||||
if (hasFullTranscriptKey) {
|
||||
console.log("(parity warning: full_transcript summary key present but transcript.md absent)");
|
||||
} else {
|
||||
console.log("transcript.md absent (full_transcript key absent in summary; valid per parity rule)");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("validate")
|
||||
.description("Validate a restore packet against the v0 schema")
|
||||
.argument("<packet-dir>", "Packet directory path")
|
||||
.option("--json", "Emit machine-readable validation report")
|
||||
.action(async (packetDir: string, opts: { json?: boolean }) => {
|
||||
const dir = resolvePath(packetDir);
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Packet-shape check: 4 required files per contract § 1.
|
||||
for (const required of REQUIRED_PACKET_FILES) {
|
||||
const p = joinPath(dir, required);
|
||||
if (!existsSync(p)) {
|
||||
errors.push({
|
||||
field: required,
|
||||
value: "<missing>",
|
||||
rule: `packet-shape: required file missing at ${p}`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Schema validation if restore-summary.json exists.
|
||||
let summary: Record<string, unknown> | null = null;
|
||||
const summaryPath = joinPath(dir, "restore-summary.json");
|
||||
if (existsSync(summaryPath)) {
|
||||
try {
|
||||
summary = JSON.parse(readFileSync(summaryPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
field: "restore-summary.json",
|
||||
value: "<invalid-json>",
|
||||
rule: `restore-summary.json parse failed: ${(err as Error).message}`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (summary) {
|
||||
const schemaResult = validateRestoreSummary(summary);
|
||||
for (const e of schemaResult.errors) errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Parity check (per contract § 1 + § 8): full_transcript summary key
|
||||
// ↔ transcript.md presence.
|
||||
if (summary) {
|
||||
const hasFullTranscriptKey = Object.prototype.hasOwnProperty.call(summary, "full_transcript");
|
||||
const transcriptExists = existsSync(joinPath(dir, "transcript.md"));
|
||||
if (hasFullTranscriptKey && !transcriptExists) {
|
||||
errors.push({
|
||||
field: "full_transcript",
|
||||
value: "<key-present-file-absent>",
|
||||
rule: "parity: full_transcript summary key present but transcript.md absent at packet directory",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!hasFullTranscriptKey && transcriptExists) {
|
||||
errors.push({
|
||||
field: "transcript.md",
|
||||
value: "<file-present-key-absent>",
|
||||
rule: "parity: transcript.md present but full_transcript summary key absent",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrorSeverity = errors.some((e) => e.severity === "error");
|
||||
const valid = !hasErrorSeverity;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ valid, errors }, null, 2));
|
||||
} else {
|
||||
if (errors.length === 0) {
|
||||
console.log(`valid: packet at ${dir} passed all checks.`);
|
||||
} else {
|
||||
console.log(`${valid ? "valid (with warnings)" : "invalid"}: ${errors.length} issue(s) at ${dir}`);
|
||||
for (const e of errors) {
|
||||
const tag = e.severity === "warning" ? "WARNING" : "ERROR";
|
||||
console.log(` [${tag}] ${e.field}: ${e.rule} (value: ${e.value})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per contract § 8: required-field violations / parity violations →
|
||||
// exit nonzero. Optional-field warnings → exit 0.
|
||||
if (hasErrorSeverity) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lif
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("restore").description("Restore a rig from a snapshot");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -28,29 +30,85 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const rigId = opts.rig;
|
||||
|
||||
const res = await client.post<{ nodes?: Array<{ nodeId: string; logicalId: string; status: string; error?: string }>; attachCommand?: string }>(`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`);
|
||||
// L3: install a SIGINT/SIGTERM handler that prints an honest message —
|
||||
// interrupting the CLI client does NOT stop daemon-side restore work.
|
||||
// Cancellation as a daemon protocol is a separate slice; ship the
|
||||
// message so operators are not surprised.
|
||||
const onSignal = () => {
|
||||
console.error("Client interrupt received; daemon-side restore may continue. Use 'rig ps --nodes' or 'rig restore-check' to follow progress.");
|
||||
process.exit(1);
|
||||
};
|
||||
process.once("SIGINT", onSignal);
|
||||
process.once("SIGTERM", onSignal);
|
||||
|
||||
const res = await client.post<{
|
||||
ok?: boolean;
|
||||
attemptId?: number;
|
||||
status?: string;
|
||||
rigId?: string;
|
||||
// Pre-restore-started error path keeps the original payload shape.
|
||||
rigResult?: string;
|
||||
blockers?: RestoreBlocker[];
|
||||
nodes?: Array<{
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
canonicalSessionName?: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
resumeCommand?: string | null;
|
||||
recoveryGuidance?: {
|
||||
summary: string;
|
||||
commands: string[];
|
||||
notes: string[];
|
||||
} | null;
|
||||
cwd?: string | null;
|
||||
}>;
|
||||
attachCommand?: string;
|
||||
}>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`,
|
||||
undefined,
|
||||
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Snapshot "${snapshotId}" or rig "${rigId}" not found. List snapshots with: rig snapshot list --rig ${rigId}`);
|
||||
process.exitCode = 1;
|
||||
} else if (res.status === 409) {
|
||||
if ((res.data as { code?: string }).code === "pre_restore_validation_failed") {
|
||||
printRestoreNotAttempted(res.data);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.error(`Restore conflict: ${(res.data as { error?: string }).error ?? "rig may still be running"}. Stop the rig first with: rig down ${rigId}`);
|
||||
process.exitCode = 1;
|
||||
} else if (res.status >= 400) {
|
||||
console.error(`Restore failed: ${(res.data as { error?: string }).error ?? "unknown error"} (HTTP ${res.status}). Check daemon logs or try a different snapshot.`);
|
||||
process.exitCode = 1;
|
||||
} else if (res.data.attemptId !== undefined) {
|
||||
// L3 success path: route returned 202 immediately after restore.started.
|
||||
console.log(`Restore attempt id: ${res.data.attemptId}`);
|
||||
console.log(`Status: ${res.data.status ?? "started"}`);
|
||||
console.log("Daemon is restoring per-node in the background; follow progress with 'rig ps --nodes' or 'rig restore-check'.");
|
||||
} else {
|
||||
// Defensive: server responded ok=true but didn't include attemptId.
|
||||
// Fall back to the legacy summary if it's present (back-compat with
|
||||
// pre-L3 daemons during rolling upgrades).
|
||||
console.log("Restore complete:");
|
||||
if (res.data.rigResult) {
|
||||
console.log(`Rig result: ${res.data.rigResult}`);
|
||||
}
|
||||
const nodes = res.data.nodes ?? [];
|
||||
for (const node of nodes) {
|
||||
const label = node.status === "failed" && node.error ? `${node.status} — ${node.error}` : node.status;
|
||||
console.log(` ${node.logicalId}: ${label}`);
|
||||
}
|
||||
printRecoveryGuidance(nodes);
|
||||
const attachCommand = (res.data as Record<string, unknown>)["attachCommand"] as string | undefined;
|
||||
if (attachCommand) {
|
||||
console.log(`Attach: ${attachCommand}`);
|
||||
}
|
||||
if (nodes.some((node) => node.status === "failed")) {
|
||||
if (res.data.rigResult === "partially_restored" || res.data.rigResult === "failed" || res.data.rigResult === "not_attempted" || nodes.some((node) => node.status === "failed")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -58,3 +116,68 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
interface RestoreBlocker {
|
||||
code: string;
|
||||
severity?: string;
|
||||
logicalId?: string;
|
||||
nodeId?: string;
|
||||
target?: string;
|
||||
path?: string;
|
||||
message: string;
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
function printRestoreNotAttempted(data: { rigResult?: string; blockers?: RestoreBlocker[]; error?: string }): void {
|
||||
console.error(`Restore blocked: ${data.error ?? "pre-restore validation failed"}`);
|
||||
if (data.rigResult) {
|
||||
console.error(`Rig result: ${data.rigResult}`);
|
||||
}
|
||||
printBlockers(data.blockers ?? []);
|
||||
}
|
||||
|
||||
function printBlockers(blockers: RestoreBlocker[]): void {
|
||||
for (const blocker of blockers) {
|
||||
const scope = blocker.logicalId ?? blocker.nodeId ?? blocker.target ?? blocker.code;
|
||||
console.error(` ${scope}: ${blocker.message}`);
|
||||
if (blocker.path) console.error(` path: ${blocker.path}`);
|
||||
console.error(` remediation: ${blocker.remediation}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printRecoveryGuidance(
|
||||
nodes: Array<{
|
||||
logicalId: string;
|
||||
status: string;
|
||||
canonicalSessionName?: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
recoveryGuidance?: { summary: string; commands: string[]; notes: string[] } | null;
|
||||
cwd?: string | null;
|
||||
}>,
|
||||
): void {
|
||||
const actionable = nodes.filter((node) =>
|
||||
(node.status === "fresh" || node.status === "failed") && node.recoveryGuidance
|
||||
);
|
||||
|
||||
if (actionable.length === 0) return;
|
||||
|
||||
console.log("\nRecovery guidance:");
|
||||
for (const node of actionable) {
|
||||
console.log(` ${node.logicalId}: ${node.recoveryGuidance!.summary}`);
|
||||
if (node.tmuxAttachCommand) {
|
||||
console.log(` attach: ${node.tmuxAttachCommand}`);
|
||||
}
|
||||
if (node.canonicalSessionName) {
|
||||
console.log(` session: ${node.canonicalSessionName}`);
|
||||
}
|
||||
if (node.cwd) {
|
||||
console.log(` cwd: ${node.cwd}`);
|
||||
}
|
||||
for (const command of node.recoveryGuidance!.commands) {
|
||||
console.log(` $ ${command}`);
|
||||
}
|
||||
for (const note of node.recoveryGuidance!.notes) {
|
||||
console.log(` note: ${note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export type SeatDeps = StatusDeps;
|
||||
|
||||
interface SeatStatusResponse {
|
||||
seat_ref: string;
|
||||
rig_id: string;
|
||||
rig_name: string;
|
||||
logical_id: string;
|
||||
pod_id: string | null;
|
||||
pod_namespace: string | null;
|
||||
runtime: string | null;
|
||||
current_occupant: string | null;
|
||||
session_status: string | null;
|
||||
startup_status: string | null;
|
||||
occupant_lifecycle: string;
|
||||
continuity_outcome: string | null;
|
||||
handover_result: string | null;
|
||||
previous_occupant: string | null;
|
||||
handover_at: string | null;
|
||||
restore_outcome: string;
|
||||
}
|
||||
|
||||
interface SeatStatusError {
|
||||
ok: false;
|
||||
code: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
guidance?: string;
|
||||
matches?: Array<{ rig_name: string; logical_id: string; current_occupant: string | null }>;
|
||||
}
|
||||
|
||||
interface SeatHandoverPlan {
|
||||
ok: true;
|
||||
dryRun: true;
|
||||
willMutate: false;
|
||||
seat: {
|
||||
ref: string;
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
logicalId: string;
|
||||
podId: string | null;
|
||||
podNamespace: string | null;
|
||||
runtime: string | null;
|
||||
};
|
||||
source: {
|
||||
mode: "fresh" | "rebuild" | "fork" | "discovered";
|
||||
ref: string | null;
|
||||
raw: string;
|
||||
defaulted: boolean;
|
||||
};
|
||||
reason: string;
|
||||
operator: string | null;
|
||||
currentOccupant: string | null;
|
||||
currentStatus: {
|
||||
sessionStatus: string | null;
|
||||
startupStatus: string | null;
|
||||
occupantLifecycle: string;
|
||||
continuityOutcome: string | null;
|
||||
handoverResult: string | null;
|
||||
previousOccupant: string | null;
|
||||
handoverAt: string | null;
|
||||
restoreOutcome: string;
|
||||
};
|
||||
phases: Array<{
|
||||
id: "prepare" | "commit";
|
||||
title: string;
|
||||
bindingUnchangedUntilComplete: boolean;
|
||||
steps: Array<{ id: string; title: string; description: string; willMutate: false }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SeatHandoverMutationResult {
|
||||
ok: true;
|
||||
dryRun: false;
|
||||
mutated: true;
|
||||
continuityTransferred: false;
|
||||
seat: SeatHandoverPlan["seat"];
|
||||
source: {
|
||||
mode: "discovered";
|
||||
ref: string;
|
||||
raw: string;
|
||||
defaulted: false;
|
||||
};
|
||||
reason: string;
|
||||
operator: string | null;
|
||||
previousOccupant: string;
|
||||
currentOccupant: string;
|
||||
previousSessionIdsSuperseded: string[];
|
||||
newSessionId: string;
|
||||
discovery: {
|
||||
id: string;
|
||||
status: "claimed";
|
||||
tmuxSession: string;
|
||||
tmuxPane: string | null;
|
||||
};
|
||||
currentStatus: SeatHandoverPlan["currentStatus"];
|
||||
handoverAt: string;
|
||||
eventSeq: number;
|
||||
sideEffects: {
|
||||
departingSessionKilled: false;
|
||||
startupContextDelivered: false;
|
||||
provenanceRecordWritten: false;
|
||||
};
|
||||
}
|
||||
|
||||
function display(value: string | null | undefined, empty = "none"): string {
|
||||
return value ?? empty;
|
||||
}
|
||||
|
||||
function printHuman(status: SeatStatusResponse): void {
|
||||
console.log(`Seat ${status.seat_ref}`);
|
||||
console.log(`Rig: ${status.rig_name}`);
|
||||
console.log(`Logical ID: ${status.logical_id}`);
|
||||
console.log(`Current occupant: ${display(status.current_occupant)}`);
|
||||
console.log(`Session: ${display(status.session_status, "unknown")}`);
|
||||
console.log(`Startup: ${display(status.startup_status, "unknown")}`);
|
||||
console.log(`Occupant lifecycle: ${status.occupant_lifecycle}`);
|
||||
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unknown")}`);
|
||||
console.log(`Handover result: ${display(status.handover_result)}`);
|
||||
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
|
||||
console.log(`Handover at: ${display(status.handover_at)}`);
|
||||
}
|
||||
|
||||
function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
|
||||
console.log(`Seat handover dry run: ${plan.seat.ref}`);
|
||||
console.log(`Rig: ${plan.seat.rigName}`);
|
||||
console.log(`Logical ID: ${plan.seat.logicalId}`);
|
||||
console.log(`Source: ${plan.source.mode}${plan.source.ref ? `:${plan.source.ref}` : ""}`);
|
||||
console.log(`Reason: ${plan.reason}`);
|
||||
console.log(`Operator: ${display(plan.operator)}`);
|
||||
console.log(`Current occupant: ${display(plan.currentOccupant)}`);
|
||||
console.log(`Current status: session=${display(plan.currentStatus.sessionStatus, "unknown")} startup=${display(plan.currentStatus.startupStatus, "unknown")} lifecycle=${plan.currentStatus.occupantLifecycle}`);
|
||||
for (const phase of plan.phases) {
|
||||
console.log(phase.title);
|
||||
for (const step of phase.steps) {
|
||||
console.log(` - ${step.title}`);
|
||||
}
|
||||
}
|
||||
console.log("No changes were made.");
|
||||
}
|
||||
|
||||
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
console.log(`Seat handover complete: ${result.seat.ref}`);
|
||||
console.log(`Rig: ${result.seat.rigName}`);
|
||||
console.log(`Logical ID: ${result.seat.logicalId}`);
|
||||
console.log(`Source: discovered:${result.discovery.id}`);
|
||||
console.log(`Reason: ${result.reason}`);
|
||||
console.log(`Operator: ${display(result.operator)}`);
|
||||
console.log(`Previous occupant: ${result.previousOccupant}`);
|
||||
console.log(`Current occupant: ${result.currentOccupant}`);
|
||||
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
|
||||
console.log("Seat binding and inventory provenance were updated.");
|
||||
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
|
||||
}
|
||||
|
||||
function printSeatError(error: SeatStatusError, fallback: string): void {
|
||||
console.error(error.message ?? error.error ?? fallback);
|
||||
if (error.guidance) {
|
||||
console.error(error.guidance);
|
||||
}
|
||||
if (error.code === "seat_ambiguous" && error.matches?.length) {
|
||||
for (const match of error.matches) {
|
||||
console.error(` ${match.logical_id}@${match.rig_name} (${display(match.current_occupant)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function seatCommand(depsOverride?: SeatDeps): Command {
|
||||
const cmd = new Command("seat")
|
||||
.description("Inspect OpenRig seat observability state");
|
||||
const getDeps = (): SeatDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Show read-only seat handover observability status")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig seat status spec-writer@openrig-pm
|
||||
rig seat status spec.writer@openrig-pm --json
|
||||
rig seat status spec.writer --json`)
|
||||
.action(async (seat: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (daemon.state !== "running" || daemon.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.get<SeatStatusResponse | SeatStatusError>(`/api/seat/status/${encodeURIComponent(seat)}`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const error = res.data as SeatStatusError;
|
||||
printSeatError(error, `Seat status failed (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
printHuman(res.data as SeatStatusResponse);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("handover")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--source <source>", "Source: discovered:<id> for live MVP; fresh, rebuild, or fork:<id> for dry-run planning")
|
||||
.option("--reason <reason>", "Why the handover is happening")
|
||||
.option("--operator <address>", "Operator initiating the handover")
|
||||
.option("--dry-run", "Plan the handover without changing topology")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Plan a safe two-phase seat handover")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig seat handover spec-writer@openrig-pm --reason context-wall --dry-run
|
||||
rig seat handover spec-writer@openrig-pm --source rebuild --reason context-wall --dry-run --json
|
||||
rig seat handover spec-writer@openrig-pm --source fork:0b0165d7 --reason successor-test --operator orch-lead@openrig-pm --dry-run
|
||||
rig seat handover spec-writer@openrig-pm --source discovered:01H... --reason mvp-proof --json`)
|
||||
.action(async (seat: string, opts: { source?: string; reason?: string; operator?: string; dryRun?: boolean; json?: boolean }) => {
|
||||
if (!opts.reason?.trim()) {
|
||||
const error: SeatStatusError = {
|
||||
ok: false,
|
||||
code: "missing_reason",
|
||||
message: "Missing required option: --reason <reason>",
|
||||
guidance: "Provide an explicit handover reason, for example: --reason context-wall",
|
||||
};
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
} else {
|
||||
printSeatError(error, "Missing required option: --reason <reason>");
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (daemon.state !== "running" || daemon.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
|
||||
source: opts.source,
|
||||
reason: opts.reason,
|
||||
operator: opts.operator,
|
||||
dryRun: opts.dryRun === true,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
printSeatError(res.data as SeatStatusError, `Seat handover failed (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data as SeatHandoverPlan | SeatHandoverMutationResult;
|
||||
if (data.dryRun) {
|
||||
printHumanHandoverPlan(data);
|
||||
} else {
|
||||
printHumanHandoverResult(data);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -3,10 +3,29 @@ import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
|
||||
export function sendCommand(depsOverride?: StatusDeps): Command {
|
||||
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
|
||||
|
||||
export interface SendDeps extends StatusDeps {
|
||||
/**
|
||||
* Cross-host hooks. Both default to the production loaders/executors; tests
|
||||
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
|
||||
* is touched.
|
||||
*/
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
crossHostRun?: (
|
||||
host: Parameters<typeof runCrossHostCommand>[0],
|
||||
argv: readonly string[],
|
||||
opts?: RunCrossHostCommandOpts,
|
||||
) => ReturnType<typeof runCrossHostCommand>;
|
||||
}
|
||||
|
||||
export function sendCommand(depsOverride?: SendDeps): Command {
|
||||
const cmd = new Command("send").description("Send a message to an agent's terminal");
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
const getDeps = (): SendDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
@@ -14,21 +33,50 @@ export function sendCommand(depsOverride?: StatusDeps): Command {
|
||||
cmd
|
||||
.argument("<session>", "Target session name (e.g. dev-impl@my-rig)")
|
||||
.argument("<text>", "Message text to send")
|
||||
.option("--verify", "Verify delivery by checking pane content after send")
|
||||
.option("--verify", "Verify pane only delivery by checking content after send")
|
||||
.option("--force", "Send even if target pane appears mid-task")
|
||||
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig send dev-impl@my-rig "Context update: QA approved. Proceed."
|
||||
rig send dev-impl@my-rig "message" --verify
|
||||
rig send dev-impl@my-rig "safe proof prompt" --wait-for-idle 30 --verify
|
||||
rig send dev-impl@my-rig "Stop and read the spec." --force
|
||||
rig send dev-impl@my-rig "message" --json
|
||||
rig send --host vm-claude-test dev-impl@my-rig "remote message" --verify
|
||||
|
||||
The two-step send pattern (paste text, wait, submit Enter) is handled
|
||||
automatically. Use --verify to confirm the message appeared in the pane.
|
||||
Use --force to override mid-task safety checks.`)
|
||||
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; json?: boolean }) => {
|
||||
automatically. Use --wait-for-idle to send only after explicit idle evidence;
|
||||
it fails closed on attention prompts, unknown activity, or timeout. Use --verify
|
||||
to confirm the message appeared in the pane only; it is not agent acknowledgement.
|
||||
Use --force to override mid-task safety checks without wait mode.
|
||||
|
||||
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
|
||||
via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
'Verified: yes/no' line is what counts and is surfaced verbatim.`)
|
||||
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; waitForIdle?: string; host?: string; json?: boolean }) => {
|
||||
const waitForIdleMs = parseWaitForIdleMs(opts.waitForIdle);
|
||||
if (opts.force && waitForIdleMs !== undefined) {
|
||||
console.error("--wait-for-idle cannot be combined with --force");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (waitForIdleMs === null) {
|
||||
console.error("--wait-for-idle must be a positive number of seconds");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
if (opts.host) {
|
||||
await runCrossHostSend(opts.host, session, text, opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
@@ -39,8 +87,8 @@ Use --force to override mid-task safety checks.`)
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session, text, verify: opts.verify, force: opts.force,
|
||||
});
|
||||
session, text, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
}, waitForIdleRequestOptions(waitForIdleMs));
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -64,3 +112,65 @@ Use --force to override mid-task safety checks.`)
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function runCrossHostSend(
|
||||
hostId: string,
|
||||
session: string,
|
||||
text: string,
|
||||
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; json?: boolean },
|
||||
deps: SendDeps,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const runner = deps.crossHostRun ?? runCrossHostCommand;
|
||||
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// Reconstruct argv for the remote `rig send` invocation. Order is positional
|
||||
// first so the remote Commander parses it the same way local does.
|
||||
const argv: string[] = ["rig", "send", session, text];
|
||||
if (opts.verify) argv.push("--verify");
|
||||
if (opts.force) argv.push("--force");
|
||||
if (opts.waitForIdle !== undefined) argv.push("--wait-for-idle", opts.waitForIdle);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: host.target },
|
||||
result,
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, opts.json);
|
||||
}
|
||||
|
||||
function parseWaitForIdleMs(value: string | undefined): number | undefined | null {
|
||||
if (value === undefined) return undefined;
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
|
||||
if (waitForIdleMs === undefined) return undefined;
|
||||
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { Command } from "commander";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import {
|
||||
CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
isCmuxSocketControlCompatible,
|
||||
readCmuxSocketControlModeFromText,
|
||||
resolveCmuxSettingsPath,
|
||||
upsertCmuxSocketControlMode,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
|
||||
export interface SetupStep {
|
||||
id: string;
|
||||
status: "pass" | "applied" | "warn" | "fail" | "skipped";
|
||||
message: string;
|
||||
reason?: string;
|
||||
fixHint?: string;
|
||||
}
|
||||
|
||||
export interface VerificationCheck {
|
||||
name: string;
|
||||
status: "pass" | "warn" | "fail" | "skipped";
|
||||
message: string;
|
||||
reason?: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigDisclosure {
|
||||
scope: "global" | "project";
|
||||
runtime: "claude-code" | "codex" | "cmux";
|
||||
path: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface SetupResult {
|
||||
profile: "core" | "full";
|
||||
platform: string;
|
||||
ready: boolean;
|
||||
steps: SetupStep[];
|
||||
runtimeConfig: RuntimeConfigDisclosure[];
|
||||
verification?: {
|
||||
checks: VerificationCheck[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetupDeps {
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
|
||||
readFile: (path: string) => string | null;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
exists: (path: string) => boolean;
|
||||
mkdirp?: (path: string) => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
||||
const CMUX_READY_ATTEMPTS = 5;
|
||||
const CMUX_READY_DELAY_MS = 1_000;
|
||||
|
||||
const CORE_STEP_IDS = [
|
||||
"brew",
|
||||
"tmux_install",
|
||||
"cmux_install",
|
||||
"claude_install",
|
||||
"claude_auth",
|
||||
"codex_install",
|
||||
"codex_auth",
|
||||
"tmux_config",
|
||||
"verify",
|
||||
];
|
||||
const FULL_EXTRA_STEP_IDS = ["jq_install", "gh_install"];
|
||||
const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude/settings.json",
|
||||
purpose: "Allow OpenRig commands without Claude permission prompts.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude.json",
|
||||
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: ".claude/settings.local.json",
|
||||
purpose: "Apply context-collector statusLine config and selected Claude settings runtime-resource fragments.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: ".mcp.json",
|
||||
purpose: "Apply selected Claude MCP runtime-resource fragments.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "codex",
|
||||
path: "~/.codex/config.toml",
|
||||
purpose: "Pre-trust managed workspaces and apply selected Codex config runtime-resource fragments.",
|
||||
},
|
||||
];
|
||||
|
||||
const DARWIN_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure = {
|
||||
scope: "global",
|
||||
runtime: "cmux",
|
||||
path: CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
|
||||
};
|
||||
|
||||
function defaultDeps(): SetupDeps {
|
||||
return {
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) =>
|
||||
execSync(cmd, {
|
||||
encoding: "utf-8",
|
||||
timeout: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}),
|
||||
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
writeFile: (p: string, c: string) => writeFileSync(p, c, "utf-8"),
|
||||
exists: (p: string) => existsSync(p),
|
||||
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function installCommand(deps: SetupDeps, cmd: string): string {
|
||||
return deps.exec(cmd, { timeoutMs: INSTALL_COMMAND_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForCmuxCapabilities(deps: SetupDeps, attempts = CMUX_READY_ATTEMPTS): Promise<boolean> {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
try {
|
||||
deps.exec("cmux capabilities --json");
|
||||
return true;
|
||||
} catch {
|
||||
if (index < attempts - 1) {
|
||||
await sleep(CMUX_READY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function tryEnableCmuxControl(deps: SetupDeps, platform: NodeJS.Platform): Promise<boolean> {
|
||||
if (platform !== "darwin") return false;
|
||||
|
||||
const settingsPath = resolveCmuxSettingsPath();
|
||||
const settingsText = deps.readFile(settingsPath);
|
||||
const currentMode = readCmuxSocketControlModeFromText(settingsText);
|
||||
if (currentMode.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.mkdirp?.(path.dirname(settingsPath));
|
||||
const next = upsertCmuxSocketControlMode(settingsText, "automation");
|
||||
if (next.changed) {
|
||||
deps.writeFile(settingsPath, next.content);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const shellReady = await waitForCmuxCapabilities(deps, 1);
|
||||
if (shellReady) {
|
||||
try {
|
||||
deps.exec("cmux reload-config");
|
||||
} catch {
|
||||
// Best effort: if reload fails, the daemon-side verification will surface it honestly.
|
||||
}
|
||||
return waitForCmuxCapabilities(deps, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
deps.exec("open -a /Applications/cmux.app");
|
||||
} catch {
|
||||
// Best effort: cmux may already be running or the app open may be blocked; capability probe decides readiness.
|
||||
}
|
||||
|
||||
return waitForCmuxCapabilities(deps);
|
||||
}
|
||||
|
||||
function buildRuntimeConfigDisclosure(platform: NodeJS.Platform): RuntimeConfigDisclosure[] {
|
||||
return platform === "darwin"
|
||||
? [...BASE_RUNTIME_CONFIG_DISCLOSURE, DARWIN_RUNTIME_CONFIG_DISCLOSURE]
|
||||
: [...BASE_RUNTIME_CONFIG_DISCLOSURE];
|
||||
}
|
||||
|
||||
async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"available" | "unavailable" | "skipped"> {
|
||||
const fetchFn = doctorDeps?.fetch;
|
||||
if (!fetchFn) return "skipped";
|
||||
const config = doctorDeps.configStore.resolve();
|
||||
const host = config.daemon.host;
|
||||
const port = config.daemon.port;
|
||||
|
||||
try {
|
||||
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
|
||||
if (!healthRes.ok) return "skipped";
|
||||
} catch {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
try {
|
||||
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
|
||||
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
|
||||
const data = (await cmuxRes.json()) as { available?: boolean };
|
||||
return data.available ? "available" : "unavailable";
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
|
||||
const profile = opts.full ? "full" : "core";
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const runtimeConfig = buildRuntimeConfigDisclosure(platform);
|
||||
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
|
||||
const steps: SetupStep[] = [];
|
||||
|
||||
if (opts.dryRun) {
|
||||
for (const id of stepIds) {
|
||||
steps.push({ id, status: "skipped", message: `Dry run: ${id} would be attempted.` });
|
||||
}
|
||||
return { profile, platform, ready: false, steps, runtimeConfig };
|
||||
}
|
||||
|
||||
// Core steps
|
||||
// 1. Homebrew (macOS-first setup path)
|
||||
let brewOk = false;
|
||||
if (platform !== "darwin") {
|
||||
steps.push({
|
||||
id: "brew",
|
||||
status: "skipped",
|
||||
message: "Skipped: Homebrew setup path is only used on macOS.",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
deps.exec("brew --version");
|
||||
brewOk = true;
|
||||
steps.push({ id: "brew", status: "pass", message: "Homebrew available." });
|
||||
} catch {
|
||||
steps.push({
|
||||
id: "brew",
|
||||
status: "fail",
|
||||
message: "Homebrew not found.",
|
||||
reason: "Homebrew is required to install tmux and cmux on macOS.",
|
||||
fixHint: "Install Homebrew: https://brew.sh",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. tmux
|
||||
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
|
||||
if (tmuxProbe.code === "not_installed") {
|
||||
if (!brewOk) {
|
||||
steps.push({ id: "tmux_install", status: "skipped", message: "Skipped: Homebrew not available.", reason: "tmux install requires Homebrew." });
|
||||
} else {
|
||||
try {
|
||||
installCommand(deps, "brew install tmux");
|
||||
steps.push({ id: "tmux_install", status: "applied", message: "Installed tmux with Homebrew." });
|
||||
} catch (err) {
|
||||
steps.push({ id: "tmux_install", status: "fail", message: `Failed to install tmux: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
} else if (!tmuxProbe.available) {
|
||||
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
|
||||
steps.push({
|
||||
id: "tmux_install",
|
||||
status: "fail",
|
||||
message: failure.message,
|
||||
reason: failure.reason,
|
||||
fixHint: failure.fix,
|
||||
});
|
||||
} else {
|
||||
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
|
||||
}
|
||||
|
||||
// 3. cmux
|
||||
const daemonCmuxBefore = await probeDaemonCmuxStatus(opts.doctorDeps);
|
||||
if (await waitForCmuxCapabilities(deps, 1)) {
|
||||
const socketMode = readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
|
||||
if (platform === "darwin" && socketMode.error) {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "cmux settings file is unreadable.",
|
||||
reason: `OpenRig could not parse ${CMUX_SETTINGS_DISCLOSURE_PATH}: ${socketMode.error}`,
|
||||
fixHint: "Repair or remove the cmux settings file, then rerun `rig setup`.",
|
||||
});
|
||||
} else if (platform === "darwin" && !isCmuxSocketControlCompatible(socketMode.mode)) {
|
||||
if (await tryEnableCmuxControl(deps, platform)) {
|
||||
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
|
||||
if (daemonCmuxAfter === "unavailable") {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "OpenRig updated cmux settings, but the running daemon still cannot control cmux.",
|
||||
reason: "The cmux settings file is now compatible, so the remaining blocker is in the live daemon/cmux session state.",
|
||||
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "applied",
|
||||
message: "Normalized cmux socket control to automation mode in ~/.config/cmux/settings.json.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "cmux shell control works, but OpenRig could not normalize cmux socket control.",
|
||||
reason: "OpenRig needs a compatible cmux socket control mode so the daemon can open CMUX surfaces reliably.",
|
||||
fixHint: `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`,
|
||||
});
|
||||
}
|
||||
} else if (daemonCmuxBefore === "unavailable") {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "cmux shell control works, but the running daemon still cannot control cmux.",
|
||||
reason: "Current cmux settings already look compatible, so the remaining blocker is outside the cmux settings file OpenRig can repair automatically.",
|
||||
fixHint: "Run `rig doctor` for the exact daemon cmux diagnosis, then restart the daemon after clearing the underlying blocker.",
|
||||
});
|
||||
} else {
|
||||
steps.push({ id: "cmux_install", status: "pass", message: "cmux available." });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
deps.exec("cmux --help");
|
||||
|
||||
if (await tryEnableCmuxControl(deps, platform)) {
|
||||
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
|
||||
if (daemonCmuxAfter === "unavailable") {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "OpenRig enabled cmux socket control, but the running daemon still cannot control cmux.",
|
||||
reason: "The cmux app and settings are now in place, so the remaining blocker is in the live daemon/cmux session state.",
|
||||
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "applied",
|
||||
message: "Enabled cmux socket control in ~/.config/cmux/settings.json.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: platform === "darwin" ? "fail" : "warn",
|
||||
message: "cmux installed but control unavailable.",
|
||||
reason: "Open CMUX workflows need cmux socket control to be enabled.",
|
||||
fixHint: platform === "darwin"
|
||||
? `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`
|
||||
: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!brewOk) {
|
||||
steps.push({ id: "cmux_install", status: "skipped", message: "Skipped: Homebrew not available." });
|
||||
} else {
|
||||
try {
|
||||
installCommand(deps, "brew install --cask cmux");
|
||||
if (await tryEnableCmuxControl(deps, platform) || await waitForCmuxCapabilities(deps, 1)) {
|
||||
steps.push({ id: "cmux_install", status: "applied", message: "Installed cmux with Homebrew." });
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: platform === "darwin" ? "fail" : "warn",
|
||||
message: "Installed cmux, but control is still unavailable.",
|
||||
reason: "Open CMUX workflows need the cmux app to expose socket control after installation.",
|
||||
fixHint: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: `Failed to install cmux: ${(err as Error).message}`,
|
||||
reason: "Open CMUX workflows stay unavailable until the cmux app and CLI are installed.",
|
||||
fixHint: "Retry `brew install --cask cmux` after connectivity stabilizes, or install cmux manually.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. tmux config
|
||||
// 4. Claude Code runtime
|
||||
let claudeInstalled = false;
|
||||
try {
|
||||
deps.exec("claude --version");
|
||||
claudeInstalled = true;
|
||||
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
|
||||
} catch {
|
||||
try {
|
||||
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
|
||||
deps.exec("claude --version");
|
||||
claudeInstalled = true;
|
||||
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "claude_install",
|
||||
status: "fail",
|
||||
message: `Failed to install Claude Code: ${(err as Error).message}`,
|
||||
reason: "The demo rig launches Claude Code nodes, so the Claude CLI must be installed on this machine.",
|
||||
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (claudeInstalled) {
|
||||
try {
|
||||
deps.exec("claude auth status");
|
||||
steps.push({ id: "claude_auth", status: "pass", message: "Claude Code authentication available." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "claude_auth",
|
||||
status: "fail",
|
||||
message: `Claude Code is installed but not ready to launch: ${(err as Error).message}`,
|
||||
reason: "The demo rig cannot launch Claude Code nodes until the Claude CLI is logged in and usable.",
|
||||
fixHint: "Run `claude auth login` or open `claude` once to complete authentication, then rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "claude_auth",
|
||||
status: "skipped",
|
||||
message: "Skipped: Claude Code is not installed.",
|
||||
reason: "Authentication cannot be checked until the Claude Code CLI is installed.",
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Codex runtime
|
||||
let codexInstalled = false;
|
||||
try {
|
||||
deps.exec("codex --version");
|
||||
codexInstalled = true;
|
||||
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
|
||||
} catch {
|
||||
try {
|
||||
installCommand(deps, "npm install -g @openai/codex");
|
||||
deps.exec("codex --version");
|
||||
codexInstalled = true;
|
||||
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "codex_install",
|
||||
status: "fail",
|
||||
message: `Failed to install Codex: ${(err as Error).message}`,
|
||||
reason: "The demo rig launches Codex nodes, so the Codex CLI must be installed on this machine.",
|
||||
fixHint: "Install Codex with `npm install -g @openai/codex`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (codexInstalled) {
|
||||
try {
|
||||
deps.exec("codex login status");
|
||||
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "fail",
|
||||
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
|
||||
reason: "The demo rig cannot launch Codex nodes until the Codex CLI is logged in and usable.",
|
||||
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "skipped",
|
||||
message: "Skipped: Codex is not installed.",
|
||||
reason: "Authentication cannot be checked until the Codex CLI is installed.",
|
||||
});
|
||||
}
|
||||
|
||||
// 6. tmux config
|
||||
const TMUX_CONF = `${process.env["HOME"] ?? "~"}/.tmux.conf`;
|
||||
const MANAGED_MARKER = "# OpenRig managed block";
|
||||
const MANAGED_BLOCK = [
|
||||
MANAGED_MARKER,
|
||||
"set -g mouse on",
|
||||
"set -g history-limit 50000",
|
||||
`# End ${MANAGED_MARKER}`,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const existing = deps.readFile(TMUX_CONF);
|
||||
if (existing && existing.includes(MANAGED_MARKER)) {
|
||||
// Replace existing managed block
|
||||
const replaced = existing.replace(
|
||||
new RegExp(`${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?# End ${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
|
||||
MANAGED_BLOCK,
|
||||
);
|
||||
deps.writeFile(TMUX_CONF, replaced);
|
||||
steps.push({ id: "tmux_config", status: "applied", message: "Updated OpenRig managed tmux config block." });
|
||||
} else if (existing) {
|
||||
deps.writeFile(TMUX_CONF, existing.trimEnd() + "\n\n" + MANAGED_BLOCK + "\n");
|
||||
steps.push({ id: "tmux_config", status: "applied", message: "Appended OpenRig managed tmux config block." });
|
||||
} else {
|
||||
deps.writeFile(TMUX_CONF, MANAGED_BLOCK + "\n");
|
||||
steps.push({ id: "tmux_config", status: "applied", message: "Created .tmux.conf with OpenRig managed block." });
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push({ id: "tmux_config", status: "warn", message: `Could not update tmux config: ${(err as Error).message}` });
|
||||
}
|
||||
|
||||
// 7. Verify
|
||||
const tmuxOk = steps.some((s) => s.id === "tmux_install" && (s.status === "pass" || s.status === "applied"));
|
||||
const anyFail = steps.some((s) => s.status === "fail");
|
||||
steps.push({
|
||||
id: "verify",
|
||||
status: anyFail ? "warn" : "pass",
|
||||
message: anyFail ? "Some setup steps failed. Run `rig doctor` for detailed diagnostics." : "Core setup verified.",
|
||||
});
|
||||
|
||||
// Full profile extras
|
||||
if (opts.full) {
|
||||
for (const tool of [{ id: "jq_install", cmd: "jq", brew: "jq" }, { id: "gh_install", cmd: "gh", brew: "gh" }]) {
|
||||
try {
|
||||
deps.exec(`${tool.cmd} --version`);
|
||||
steps.push({ id: tool.id, status: "pass", message: `${tool.cmd} available.` });
|
||||
} catch {
|
||||
if (!brewOk) {
|
||||
steps.push({ id: tool.id, status: "skipped", message: `Skipped: Homebrew not available.` });
|
||||
} else {
|
||||
try {
|
||||
installCommand(deps, `brew install ${tool.brew}`);
|
||||
steps.push({ id: tool.id, status: "applied", message: `Installed ${tool.cmd} with Homebrew.` });
|
||||
} catch {
|
||||
steps.push({ id: tool.id, status: "warn", message: `Failed to install ${tool.cmd}.`, fixHint: `Install ${tool.cmd} manually.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run doctor-backed verification if not dry-run and doctorDeps available
|
||||
let verification: SetupResult["verification"];
|
||||
if (!opts.dryRun && opts.doctorDeps) {
|
||||
const doctorDeps = opts.doctorDeps;
|
||||
const doctor = runDoctorChecks(doctorDeps);
|
||||
const asyncResults = await Promise.all(doctor.asyncChecks);
|
||||
const allDoctorChecks = [...doctor.checks, ...asyncResults];
|
||||
verification = {
|
||||
checks: allDoctorChecks.map((c) => ({
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
message: c.message,
|
||||
...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.fix ? { fix: c.fix } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ready = no fail statuses in steps or verification checks
|
||||
const stepsFailed = steps.some((s) => s.status === "fail");
|
||||
const verificationFailed = verification?.checks.some((c) => c.status === "fail") ?? false;
|
||||
const ready = !stepsFailed && !verificationFailed;
|
||||
return { profile, platform, ready, steps, runtimeConfig, ...(verification ? { verification } : {}) };
|
||||
}
|
||||
|
||||
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
|
||||
const platform = setupDeps.platform ?? process.platform;
|
||||
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
|
||||
return {
|
||||
exists: setupDeps.exists,
|
||||
baseDir,
|
||||
readFile: setupDeps.readFile,
|
||||
exec: setupDeps.exec,
|
||||
checkPort: async (port: number) => {
|
||||
const net = await import("node:net");
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new net.default.Socket();
|
||||
socket.once("connect", () => { socket.destroy(); resolve(false); });
|
||||
socket.once("error", () => resolve(true));
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
},
|
||||
configStore: new ConfigStore(),
|
||||
platform: platform as NodeJS.Platform,
|
||||
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
|
||||
checkWritable: (p: string) => accessSync(p, constants.W_OK),
|
||||
fetch: globalThis.fetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCommand(depsOverride?: SetupDeps): Command {
|
||||
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
|
||||
|
||||
cmd
|
||||
.option("--dry-run", "Show the plan without making changes")
|
||||
.option("--json", "Machine-readable JSON output")
|
||||
.option("--full", "Install broader operator workstation tools")
|
||||
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean }) => {
|
||||
const deps = depsOverride ?? defaultDeps();
|
||||
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
|
||||
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, doctorDeps });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!opts.dryRun && !result.ready) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nProfile: ${result.profile}`);
|
||||
console.log(`Platform: ${result.platform}\n`);
|
||||
console.log("OpenRig may modify runtime config in these locations:");
|
||||
for (const item of result.runtimeConfig) {
|
||||
console.log(` - [${item.scope}] ${item.runtime} ${item.path} — ${item.purpose}`);
|
||||
}
|
||||
console.log(" - Note: already-running adopted sessions may need restart to pick up runtime config changes.\n");
|
||||
|
||||
for (const step of result.steps) {
|
||||
const icon = step.status === "pass" ? "OK" : step.status === "applied" ? "APPLIED" : step.status === "warn" ? "WARN" : step.status === "skipped" ? "SKIP" : "FAIL";
|
||||
console.log(` [${icon}] ${step.id}: ${step.message}`);
|
||||
if (step.reason) console.log(` Why: ${step.reason}`);
|
||||
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
|
||||
}
|
||||
|
||||
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
|
||||
if (!opts.dryRun && !result.ready) process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { getDefaultOpenRigPath } from "../openrig-compat.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
@@ -17,6 +30,114 @@ interface LibraryEntry {
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface AddSpecSource {
|
||||
inputPath: string;
|
||||
yamlPath: string;
|
||||
installName: string;
|
||||
installKind: "file" | "directory";
|
||||
libraryEntrySuffix: string;
|
||||
}
|
||||
|
||||
function normalizePathForMatch(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function requireRegularFile(path: string, label: string): void {
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} must not be a symlink: ${path}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${label} must be a regular file: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTreeHasNoSymlinks(root: string): void {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const absPath = join(current, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Spec directories must not contain symlinks: ${absPath}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(absPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAddSpecSource(inputPath: string): AddSpecSource {
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error(`File not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
const stat = lstatSync(inputPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Spec path must not be a symlink: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
return {
|
||||
inputPath,
|
||||
yamlPath: inputPath,
|
||||
installName: basename(inputPath),
|
||||
installKind: "file",
|
||||
libraryEntrySuffix: basename(inputPath),
|
||||
};
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Spec path must be a YAML file or spec directory: ${inputPath}`);
|
||||
}
|
||||
|
||||
const rootSpec = ["rig.yaml", "rig.yml", "agent.yaml", "agent.yml"]
|
||||
.map((candidate) => join(inputPath, candidate))
|
||||
.find((candidate) => existsSync(candidate));
|
||||
if (!rootSpec) {
|
||||
throw new Error(`Spec directory must contain rig.yaml or agent.yaml: ${inputPath}`);
|
||||
}
|
||||
requireRegularFile(rootSpec, "Root spec file");
|
||||
|
||||
const installName = basename(inputPath);
|
||||
return {
|
||||
inputPath,
|
||||
yamlPath: rootSpec,
|
||||
installName,
|
||||
installKind: "directory",
|
||||
libraryEntrySuffix: normalizePathForMatch(join(installName, basename(rootSpec))),
|
||||
};
|
||||
}
|
||||
|
||||
function installSpecSource(source: AddSpecSource, userRoot: string): string {
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
const dest = join(userRoot, source.installName);
|
||||
|
||||
if (source.installKind === "file") {
|
||||
requireRegularFile(source.inputPath, "Spec file");
|
||||
copyFileSync(source.inputPath, dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
if (existsSync(dest)) {
|
||||
throw new Error(`A spec directory already exists at ${dest}. Remove or rename it before adding this spec.`);
|
||||
}
|
||||
|
||||
assertTreeHasNoSymlinks(source.inputPath);
|
||||
const tempParent = mkdtempSync(join(userRoot, ".spec-add-"));
|
||||
const tempDest = join(tempParent, source.installName);
|
||||
try {
|
||||
cpSync(source.inputPath, tempDest, { recursive: true, errorOnExist: true, force: false });
|
||||
renameSync(tempDest, dest);
|
||||
} catch (err) {
|
||||
rmSync(tempParent, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
rmSync(tempParent, { recursive: true, force: true });
|
||||
return dest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared name resolution for library specs.
|
||||
* Returns the matching entry, or throws with guidance on ambiguity/not-found.
|
||||
@@ -44,12 +165,19 @@ export async function resolveLibrarySpec(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available specs.`
|
||||
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
|
||||
);
|
||||
}
|
||||
|
||||
export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("specs").description("Browse, preview, and manage the spec library");
|
||||
const cmd = new Command("specs")
|
||||
.description("Browse, preview, and manage the spec library, including managed apps")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig specs ls
|
||||
rig specs preview secrets-manager
|
||||
rig specs show vault-specialist
|
||||
`);
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
@@ -66,7 +194,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
// specs ls
|
||||
cmd.command("ls")
|
||||
.description("List library specs")
|
||||
.description("List library rigs, agents, and managed apps")
|
||||
.option("--kind <kind>", "Filter by kind (rig or agent)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { kind?: string; json?: boolean }) => {
|
||||
@@ -125,7 +253,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
// specs preview
|
||||
cmd.command("preview")
|
||||
.argument("<name-or-id>", "Spec name or library ID")
|
||||
.description("Show structured spec review")
|
||||
.description("Show structured spec review, including managed app details")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
@@ -160,20 +288,13 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
// specs add
|
||||
cmd.command("add")
|
||||
.argument("<path>", "Path to YAML spec file")
|
||||
.description("Add a spec to the user library")
|
||||
.argument("<path>", "Path to YAML spec file or spec directory")
|
||||
.description("Add a spec file or full spec directory to the user library")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (filePath: string, opts: { json?: boolean }) => {
|
||||
.action(async (inputPath: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const { readFileSync, copyFileSync, existsSync } = await import("node:fs");
|
||||
const { join, basename } = await import("node:path");
|
||||
const { homedir } = await import("node:os");
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const yaml = readFileSync(filePath, "utf-8");
|
||||
const source = resolveAddSpecSource(inputPath);
|
||||
const yaml = readFileSync(source.yamlPath, "utf-8");
|
||||
const client = await getClient();
|
||||
|
||||
// Validate via daemon
|
||||
@@ -189,16 +310,16 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
// Copy to user library
|
||||
const userRoot = getDefaultOpenRigPath("specs");
|
||||
const { mkdirSync } = await import("node:fs");
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
const dest = join(userRoot, basename(filePath));
|
||||
copyFileSync(filePath, dest);
|
||||
const dest = installSpecSource(source, userRoot);
|
||||
|
||||
// Sync and find the new entry
|
||||
const syncRes = await client.post<LibraryEntry[]>("/api/specs/library/sync");
|
||||
const entries = syncRes.data ?? [];
|
||||
const name = (res.data as Record<string, unknown>)["name"] as string ?? basename(filePath);
|
||||
const newEntry = entries.find((e) => e.name === name && e.sourcePath.endsWith(basename(filePath)));
|
||||
const name = (res.data as Record<string, unknown>)["name"] as string ?? source.installName;
|
||||
const newEntry = entries.find((e) => (
|
||||
e.name === name &&
|
||||
normalizePathForMatch(e.sourcePath).endsWith(source.libraryEntrySuffix)
|
||||
));
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ name, kind, path: dest, id: newEntry?.id ?? null, entry: newEntry ?? null }));
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig stream` — coordination primitive L1 commands (PL-004 Phase A).
|
||||
*
|
||||
* Backed by `/api/stream`. Operates only via the daemon HTTP API.
|
||||
* Does NOT touch the POC `rigx-stream-proto` filesystem state.
|
||||
*/
|
||||
|
||||
export interface StreamDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: StreamDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function streamCommand(depsOverride?: StreamDeps): Command {
|
||||
const cmd = new Command("stream").description("Coordination L1 — append-only intake stream");
|
||||
const getDeps = (): StreamDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("emit")
|
||||
.description("Append a stream item")
|
||||
.requiredOption("--source <session>", "Source session (e.g. velocity-driver@openrig-velocity-claude)")
|
||||
.requiredOption("--body <text>", "Stream item body")
|
||||
.option("--hint-destination <session>", "Hint at intended destination seat")
|
||||
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
|
||||
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
|
||||
.option("--hint-tags <tags>", "Comma-separated hint tags")
|
||||
.option("--interrupt", "Mark item as interrupting")
|
||||
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
body: string;
|
||||
hintDestination?: string;
|
||||
hintType?: string;
|
||||
hintUrgency?: string;
|
||||
hintTags?: string;
|
||||
interrupt?: boolean;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.hintTags ? opts.hintTags.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/stream/emit", {
|
||||
streamItemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
body: opts.body,
|
||||
hintDestination: opts.hintDestination ?? null,
|
||||
hintType: opts.hintType ?? null,
|
||||
hintUrgency: opts.hintUrgency ?? null,
|
||||
hintTags: tags,
|
||||
interrupt: opts.interrupt ?? false,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List stream items chronologically")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--hint-destination <session>", "Filter by hint destination")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--after <sortKey>", "Cursor pagination — return items after this sort key")
|
||||
.option("--include-archived", "Include archived items")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source?: string;
|
||||
hintDestination?: string;
|
||||
limit: string;
|
||||
after?: string;
|
||||
includeArchived?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.hintDestination) params.set("hintDestination", opts.hintDestination);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
if (opts.after) params.set("afterSortKey", opts.after);
|
||||
if (opts.includeArchived) params.set("includeArchived", "true");
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/stream/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <streamItemId>")
|
||||
.description("Fetch one stream item by id")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (streamItemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("archive <streamItemId>")
|
||||
.description("Soft-archive a stream item (audit row preserved)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (streamItemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}/archive`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -5,12 +5,26 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: LifecycleDeps }): Command {
|
||||
const cmd = new Command("up").description("Bootstrap a rig from a spec or bundle");
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function upCommand(
|
||||
depsOverride?: StatusDeps & {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
preflightExec?: (cmd: string) => Promise<string>;
|
||||
},
|
||||
): Command {
|
||||
const cmd = new Command("up")
|
||||
.description("Launch a rig or managed app from a spec, library entry, or bundle")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig up secrets-manager
|
||||
rig up ./rig.yaml
|
||||
rig up ./demo.rigbundle --target ~/work
|
||||
`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<source>", "Path to .yaml rig spec or .rigbundle")
|
||||
.argument("<source>", "Path to a .yaml rig spec or .rigbundle, or a library name such as secrets-manager")
|
||||
.option("--plan", "Plan mode — preview without executing")
|
||||
.option("--yes", "Auto-approve trusted actions")
|
||||
.option("--cwd <path>", "Override launch working directory for all members for this run only")
|
||||
@@ -34,8 +48,10 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
|
||||
const configStore = new ConfigStore();
|
||||
resolvedConfig = configStore.resolve();
|
||||
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
|
||||
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
const preflight = new SystemPreflight({
|
||||
exec: async (cmd) => execSync(cmd, { encoding: "utf-8" }),
|
||||
exec: preflightExec,
|
||||
configStore,
|
||||
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
|
||||
openrigHome: OPENRIG_DIR,
|
||||
@@ -85,14 +101,28 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
let sourceRef = isRigName ? source : nodePath.resolve(source);
|
||||
|
||||
// If it looks like a name, check for library spec match
|
||||
let defaultLibraryCwdOverride: string | undefined;
|
||||
// Cache rig summaries so we can both detect ambiguity AND derive lifecycleState
|
||||
// for "Recovering ..." vs "Turning on ..." wording (post-L2).
|
||||
let rigSummariesCache: Array<{ id: string; name: string; lifecycleState?: string }> | null = null;
|
||||
const fetchRigSummaries = async () => {
|
||||
if (rigSummariesCache !== null) return rigSummariesCache;
|
||||
try {
|
||||
const res = await client.get<Array<{ id: string; name: string; lifecycleState?: string }>>("/api/rigs/summary");
|
||||
rigSummariesCache = res.data ?? [];
|
||||
} catch {
|
||||
rigSummariesCache = [];
|
||||
}
|
||||
return rigSummariesCache;
|
||||
};
|
||||
if (isRigName) {
|
||||
try {
|
||||
const { resolveLibrarySpec } = await import("./specs.js");
|
||||
const entry = await resolveLibrarySpec(client, source);
|
||||
// Library match found — check for existing-rig collision
|
||||
// Use /api/rigs/summary which mirrors findRigsByName (includes stopped rigs)
|
||||
const rigRes = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
|
||||
const rigMatches = (rigRes.data ?? []).filter((r) => r.name === source);
|
||||
const rigSummaries = await fetchRigSummaries();
|
||||
const rigMatches = rigSummaries.filter((r) => r.name === source);
|
||||
if (rigMatches.length > 0) {
|
||||
console.error(`'${source}' is ambiguous — it matches both an existing rig restore target and a library spec.`);
|
||||
console.error(` To launch the library spec: rig up ${entry.sourcePath}`);
|
||||
@@ -102,6 +132,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
return;
|
||||
}
|
||||
sourceRef = entry.sourcePath;
|
||||
if (!opts.cwd && entry.kind === "rig" && entry.sourceType === "builtin") {
|
||||
defaultLibraryCwdOverride = process.cwd();
|
||||
}
|
||||
} catch (resolveErr) {
|
||||
// Ambiguity within library — surface it
|
||||
if ((resolveErr as Error).message?.includes("ambiguous")) {
|
||||
@@ -111,6 +144,21 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
}
|
||||
// Not found or other error — proceed with existing rig-name behavior
|
||||
}
|
||||
|
||||
// Wording divergence (post-L2): if sourceRef stayed as the rig name we are
|
||||
// routing through the existing-rig restore path. Print "Recovering ..." or
|
||||
// "Turning on ..." per the rig's derived lifecycleState. Help text honesty:
|
||||
// "Recover" describes what `rig up` does; it does not promise success before
|
||||
// tester L4 VM proof completes.
|
||||
if (sourceRef === source && !opts.json) {
|
||||
const summaries = await fetchRigSummaries();
|
||||
const match = summaries.find((r) => r.name === source);
|
||||
if (match?.lifecycleState === "recoverable") {
|
||||
console.log(`Recovering rig "${source}" from latest snapshot...`);
|
||||
} else if (match?.lifecycleState === "stopped") {
|
||||
console.log(`Turning on rig "${source}"...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
|
||||
@@ -120,13 +168,15 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
});
|
||||
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = res.status === 409 ? 1 : 2;
|
||||
const rigResult = res.data["rigResult"] as string | undefined;
|
||||
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted") process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,6 +190,8 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
} else if (code === "preflight_failed") {
|
||||
const errors = (res.data["errors"] as string[]) ?? [];
|
||||
console.error(`Preflight check failed:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: resolve the issues above and retry.`);
|
||||
} else if (code === "pre_restore_validation_failed") {
|
||||
printRestoreNotAttempted(res.data as RestoreNotAttemptedData);
|
||||
} else {
|
||||
const errorText = String(res.data["error"] ?? "unknown error");
|
||||
console.error(`Up failed: ${errorText} (HTTP ${res.status}). Check daemon logs or validate your spec with: rig spec validate <path>`);
|
||||
@@ -163,7 +215,17 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
// Existing-rig power-on handoff
|
||||
const rigId = res.data["rigId"] as string;
|
||||
const rigName = res.data["rigName"] as string | undefined;
|
||||
const rigResult = res.data["rigResult"] as string | undefined;
|
||||
const snapshotKind = res.data["snapshotKind"] as string | undefined;
|
||||
// L3b: when the daemon falls back to a non-auto-pre-down snapshot, surface
|
||||
// the kind so operators see the manual fallback explicitly. The note is
|
||||
// printed BEFORE the "Rig restored" line so it's visible in the typical
|
||||
// top-of-output scan.
|
||||
if (snapshotKind && snapshotKind !== "auto-pre-down") {
|
||||
console.log(`Restoring from manual snapshot (kind=${snapshotKind}); no auto-pre-down snapshot available.`);
|
||||
}
|
||||
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
|
||||
if (rigResult) console.log(`Result: ${rigResult}`);
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
for (const n of nodes) {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
@@ -177,7 +239,7 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
if (attachCommand) {
|
||||
console.log(`Attach: ${attachCommand}`);
|
||||
}
|
||||
if (nodes.some((n) => n.status === "failed")) {
|
||||
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
@@ -213,3 +275,33 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
interface RestoreBlocker {
|
||||
code: string;
|
||||
severity?: string;
|
||||
logicalId?: string;
|
||||
nodeId?: string;
|
||||
target?: string;
|
||||
path?: string;
|
||||
message: string;
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
interface RestoreNotAttemptedData {
|
||||
error?: string;
|
||||
rigResult?: string;
|
||||
blockers?: RestoreBlocker[];
|
||||
}
|
||||
|
||||
function printRestoreNotAttempted(data: RestoreNotAttemptedData): void {
|
||||
console.error(`Restore blocked: ${data.error ?? "pre-restore validation failed"}`);
|
||||
if (data.rigResult) {
|
||||
console.error(`Result: ${data.rigResult}`);
|
||||
}
|
||||
for (const blocker of data.blockers ?? []) {
|
||||
const scope = blocker.logicalId ?? blocker.nodeId ?? blocker.target ?? blocker.code;
|
||||
console.error(` ${scope}: ${blocker.message}`);
|
||||
if (blocker.path) console.error(` path: ${blocker.path}`);
|
||||
console.error(` remediation: ${blocker.remediation}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig view` — coordination primitive L5 (view) commands (PL-004 Phase B).
|
||||
*
|
||||
* Backed by `/api/views`. 6 built-in views (recently-active, founder,
|
||||
* pod-load, escalations, held, activity) + custom view registration.
|
||||
*/
|
||||
|
||||
export interface ViewDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: ViewDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function viewCommand(depsOverride?: ViewDeps): Command {
|
||||
const cmd = new Command("view").description(
|
||||
"Coordination L5 — daemon-backed views over coordination state",
|
||||
);
|
||||
const getDeps = (): ViewDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List built-in + custom views")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/views/list");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <viewName>")
|
||||
.description(
|
||||
"Run a view (built-in or custom). Built-in views: recently-active, founder, pod-load, escalations, held, activity",
|
||||
)
|
||||
.option("--rig <rig>", "Filter by rig name (matches destination_session OR source_session @<rig>)")
|
||||
.option("--limit <n>", "Result row limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (viewName: string, opts: { rig?: string; limit: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.rig) params.set("rig", opts.rig);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
const query = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const path = `/api/views/${encodeURIComponent(viewName)}${query ? `?${query}` : ""}`;
|
||||
const res = await client.get<unknown>(path);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("register")
|
||||
.description("Register or update a custom view")
|
||||
.requiredOption("--name <name>", "Custom view name (must NOT collide with built-in names)")
|
||||
.requiredOption("--definition <sql>", "SQL definition (operator-supplied; not validated for taxonomy)")
|
||||
.requiredOption("--session <session>", "Registering operator session")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { name: string; definition: string; session: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/views/custom/register", {
|
||||
viewName: opts.name,
|
||||
definition: opts.definition,
|
||||
registeredBySession: opts.session,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig watchdog` — coordination primitive Watchdog (PL-004 Phase C).
|
||||
*
|
||||
* Backed by `/api/watchdog`. Operates only via the daemon HTTP API.
|
||||
*
|
||||
* Per PRD § Watchdog: scheduler is daemon-native and joins the
|
||||
* supervision tree. Phase D includes workflow-keepalive alongside the
|
||||
* Phase C watchdog policies.
|
||||
*/
|
||||
|
||||
export interface WatchdogDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: WatchdogDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
const cmd = new Command("watchdog").description(
|
||||
"Coordination Watchdog — daemon-native scheduler for periodic-reminder, artifact-pool-ready, edge-artifact-required (PL-004 Phase C; workflow-keepalive ships in Phase D)",
|
||||
);
|
||||
const getDeps = (): WatchdogDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("register")
|
||||
.description("Register a new watchdog job from a YAML spec file")
|
||||
.requiredOption("--spec <path>", "Path to YAML spec file (policy + target + interval + context)")
|
||||
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive)")
|
||||
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
|
||||
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
|
||||
.requiredOption("--registered-by <session>", "Registering session (for audit)")
|
||||
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
|
||||
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
spec: string;
|
||||
policy: string;
|
||||
targetSession: string;
|
||||
intervalSeconds: string;
|
||||
registeredBy: string;
|
||||
activeWakeIntervalSeconds?: string;
|
||||
scanIntervalSeconds?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
let specYaml: string;
|
||||
try {
|
||||
specYaml = readFileSync(opts.spec, "utf-8");
|
||||
} catch (err) {
|
||||
console.error(`Failed to read spec file ${opts.spec}: ${err instanceof Error ? err.message : err}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/watchdog/register", {
|
||||
policy: opts.policy,
|
||||
specYaml,
|
||||
targetSession: opts.targetSession,
|
||||
intervalSeconds: Number.parseInt(opts.intervalSeconds, 10),
|
||||
activeWakeIntervalSeconds: opts.activeWakeIntervalSeconds
|
||||
? Number.parseInt(opts.activeWakeIntervalSeconds, 10)
|
||||
: undefined,
|
||||
scanIntervalSeconds: opts.scanIntervalSeconds
|
||||
? Number.parseInt(opts.scanIntervalSeconds, 10)
|
||||
: undefined,
|
||||
registeredBySession: opts.registeredBy,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List all watchdog jobs")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/watchdog/list");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <jobId>")
|
||||
.description("Show one watchdog job")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (jobId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("status <jobId>")
|
||||
.description("Show one watchdog job + recent evaluation history")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (jobId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}/status`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("stop <jobId>")
|
||||
.description("Stop a watchdog job (operator-stopped; scheduler skips it)")
|
||||
.option("--reason <text>", "Stop reason (free-form; recorded in terminal_reason)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (jobId: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}/stop`, {
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -5,6 +5,26 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
|
||||
interface WhoamiCliOptions {
|
||||
nodeId?: string;
|
||||
session?: string;
|
||||
host?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface WhoamiDeps extends StatusDeps {
|
||||
/** Cross-host hooks; mirrors PsDeps/SendDeps shape. Tests inject mocks. */
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
crossHostRun?: (
|
||||
host: Parameters<typeof runCrossHostCommand>[0],
|
||||
argv: readonly string[],
|
||||
opts?: RunCrossHostCommandOpts,
|
||||
) => ReturnType<typeof runCrossHostCommand>;
|
||||
}
|
||||
|
||||
interface WhoamiIdentity {
|
||||
rigName: string;
|
||||
@@ -133,9 +153,9 @@ export function resolveIdentitySource(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function whoamiCommand(depsOverride?: StatusDeps): Command {
|
||||
export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
const cmd = new Command("whoami").description("Show current managed identity in an OpenRig topology");
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
const getDeps = (): WhoamiDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
@@ -143,16 +163,25 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
|
||||
cmd
|
||||
.option("--node-id <id>", "Resolve by node ID")
|
||||
.option("--session <name>", "Resolve by session name")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { nodeId?: string; session?: string; json?: boolean }) => {
|
||||
.action(async (opts: WhoamiCliOptions) => {
|
||||
const deps = getDeps();
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
// Runs BEFORE local identity-source resolution: the remote rig has its
|
||||
// own daemon and identity context; we just forward the raw flags.
|
||||
if (opts.host) {
|
||||
await runCrossHostWhoami(opts.host, opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = resolveIdentitySource(opts);
|
||||
if (!source) {
|
||||
console.error("Cannot determine identity. Run inside an OpenRig-managed session, or use --session or --node-id.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
const partial = buildPartialWhoamiResult(source);
|
||||
@@ -249,3 +278,54 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function runCrossHostWhoami(
|
||||
hostId: string,
|
||||
opts: WhoamiCliOptions,
|
||||
deps: WhoamiDeps,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const runner = deps.crossHostRun ?? runCrossHostCommand;
|
||||
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// Reconstruct argv. `rig whoami` has no positional args; just the resolution
|
||||
// flags. Identity resolution happens on the REMOTE rig (each host has its
|
||||
// own daemon + tmux + identity context); we don't pre-resolve locally.
|
||||
const argv: string[] = ["rig", "whoami"];
|
||||
if (opts.nodeId !== undefined) argv.push("--node-id", opts.nodeId);
|
||||
if (opts.session !== undefined) argv.push("--session", opts.session);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
|
||||
if (opts.json) {
|
||||
if (result.ok) {
|
||||
// Verbatim remote stdout passthrough — the remote `rig whoami --json`
|
||||
// already produced the correct JSON envelope; we do NOT double-wrap.
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, false);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig workflow` — daemon-native Workflow Runtime (PL-004 Phase D).
|
||||
*
|
||||
* Backed by `/api/workflow`. Operates only via the daemon HTTP API.
|
||||
*
|
||||
* Per PRD § L4 Workflow Runtime: workflow specs are markdown/YAML
|
||||
* authoritative, daemon caches a read-through copy. Owner-as-author
|
||||
* + workflow-as-transactional-scribe: owner closure + next-qitem
|
||||
* projection happen in the SAME daemon transaction. Lost handoffs
|
||||
* are impossible by design.
|
||||
*/
|
||||
|
||||
export interface WorkflowDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: WorkflowDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
const cmd = new Command("workflow").description(
|
||||
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
|
||||
);
|
||||
const getDeps = (): WorkflowDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (specPath: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/validate", { specPath });
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("instantiate <specPath>")
|
||||
.description("Create a workflow instance + entry-step qitem from a spec")
|
||||
.requiredOption("--root-objective <text>", "Root objective for the run")
|
||||
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
|
||||
.option("--entry-owner <session>", "Override default entry-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (specPath: string, opts: {
|
||||
rootObjective: string;
|
||||
createdBy: string;
|
||||
entryOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/instantiate", {
|
||||
specPath,
|
||||
rootObjective: opts.rootObjective,
|
||||
createdBySession: opts.createdBy,
|
||||
entryOwnerSession: opts.entryOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("project")
|
||||
.description("Close a current packet AND project the next-step packet (transactional-scribe; one daemon transaction)")
|
||||
.requiredOption("--instance <id>", "Workflow instance id")
|
||||
.requiredOption("--current-packet <qitem-id>", "qitem being closed (must be on instance frontier)")
|
||||
.requiredOption("--exit <kind>", "Closure exit kind: handoff | waiting | done | failed")
|
||||
.requiredOption("--actor-session <session>", "Session closing the packet (owner-as-author)")
|
||||
.option("--result-note <text>", "Closure result note (audit context)")
|
||||
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
|
||||
.option("--next-owner <session>", "Override default next-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
instance: string;
|
||||
currentPacket: string;
|
||||
exit: "handoff" | "waiting" | "done" | "failed";
|
||||
actorSession: string;
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
nextOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/project", {
|
||||
instanceId: opts.instance,
|
||||
currentPacketId: opts.currentPacket,
|
||||
exit: opts.exit,
|
||||
actorSession: opts.actorSession,
|
||||
resultNote: opts.resultNote,
|
||||
blockedOn: opts.blockedOn,
|
||||
nextOwnerSession: opts.nextOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
const qs = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <instanceId>")
|
||||
.description("Show one workflow instance")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("trace <instanceId>")
|
||||
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/trace`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("continue <instanceId>")
|
||||
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CrossHostResult } from "./cross-host-executor.js";
|
||||
|
||||
/**
|
||||
* Shared CLI-side helpers for commands that gate on `--host`. Centralizes
|
||||
* the structured failure formatting + JSON envelope so `send.ts` and
|
||||
* `capture.ts` (and any future v1 cross-host command) format identically.
|
||||
*/
|
||||
|
||||
export function emitCrossHostError(hostId: string, code: string, message: string, json?: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, cross_host: { host: hostId }, failedStep: code, error: message }));
|
||||
} else {
|
||||
console.error(`cross-host (host=${hostId}): ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function emitCrossHostFailure(
|
||||
hostId: string,
|
||||
target: string,
|
||||
result: CrossHostResult,
|
||||
json?: boolean,
|
||||
): void {
|
||||
if (result.ok) return;
|
||||
const message = formatCrossHostFailure(hostId, target, result);
|
||||
if (json) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
cross_host: { host: hostId, target },
|
||||
failedStep: result.failedStep,
|
||||
error: message,
|
||||
...(result.failedStep === "permission-gate" && result.hint ? { hint: result.hint } : {}),
|
||||
}));
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function formatCrossHostFailure(
|
||||
hostId: string,
|
||||
target: string,
|
||||
result: Extract<CrossHostResult, { ok: false }>,
|
||||
): string {
|
||||
switch (result.failedStep) {
|
||||
case "ssh-unreachable":
|
||||
return `ssh to host=${hostId} (target=${target}) failed: ${oneLine(result.sshStderr)}. Verify SSH access and host registry config.`;
|
||||
case "permission-gate":
|
||||
return `ssh to host=${hostId} (target=${target}) hit a permission/auth gate: ${oneLine(result.sshStderr)}. ${result.hint ?? ""}`.trim();
|
||||
case "remote-daemon-unreachable":
|
||||
return `remote rig command on host=${hostId} could not reach the remote daemon (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}. Start the daemon on the remote with 'ssh ${target} rig daemon start'.`;
|
||||
case "remote-command-failed":
|
||||
return `remote rig command on host=${hostId} failed (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function oneLine(s: string): string {
|
||||
return s.split("\n").map((l) => l.trim()).filter(Boolean).join(" | ").slice(0, 400);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
||||
import type { HostEntry } from "./host-registry.js";
|
||||
|
||||
/**
|
||||
* Structured result of a cross-host command. Distinguishes SSH-layer failures
|
||||
* (couldn't reach the remote shell) from remote-command failures (reached the
|
||||
* remote shell, but the command itself returned non-zero). This distinction
|
||||
* is load-bearing: collapsing both into "failed" hides the operational
|
||||
* difference between "fix your SSH" and "fix your remote rig daemon."
|
||||
*/
|
||||
export type CrossHostResult =
|
||||
| { ok: true; failedStep: "none"; stdout: string; stderr: string; remoteExitCode: 0 }
|
||||
| { ok: false; failedStep: "ssh-unreachable"; sshStderr: string }
|
||||
| { ok: false; failedStep: "permission-gate"; sshStderr: string; hint?: string }
|
||||
| { ok: false; failedStep: "remote-daemon-unreachable"; stdout: string; stderr: string; remoteExitCode: number }
|
||||
| { ok: false; failedStep: "remote-command-failed"; stdout: string; stderr: string; remoteExitCode: number };
|
||||
|
||||
export type FailedStep = CrossHostResult["failedStep"];
|
||||
|
||||
/**
|
||||
* Pluggable spawn function for testing. The default uses Node's
|
||||
* `child_process.spawn` directly. Tests inject a mock that returns a
|
||||
* controllable process-like object.
|
||||
*/
|
||||
export type SpawnFn = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
) => ChildProcess;
|
||||
|
||||
export interface RunCrossHostCommandOpts {
|
||||
/** Optional stdin to write to the spawned ssh process (forwarded to remote stdin). */
|
||||
stdin?: string;
|
||||
/** Inject a non-default spawn function (used in tests). */
|
||||
spawn?: SpawnFn;
|
||||
/** Connect timeout in seconds for the underlying ssh invocation. Default: 10. */
|
||||
connectTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a remote rig command via single-hop ssh. Returns a structured result
|
||||
* that maps each failure mode named in the dossier to a distinct
|
||||
* `failedStep` value.
|
||||
*
|
||||
* Important: this function does NOT collapse remote `--verify` semantics into
|
||||
* the SSH exit code. Callers that pass `--verify` get the remote command's
|
||||
* stdout (containing `Verified: yes`/`no`) verbatim; the remote rig is
|
||||
* authoritative on verification. SSH success (exit 0) just means "we reached
|
||||
* the remote shell and it ran the command", not "the verify passed."
|
||||
*/
|
||||
export async function runCrossHostCommand(
|
||||
host: HostEntry,
|
||||
argv: readonly string[],
|
||||
opts: RunCrossHostCommandOpts = {},
|
||||
): Promise<CrossHostResult> {
|
||||
if (host.transport !== "ssh") {
|
||||
return {
|
||||
ok: false,
|
||||
failedStep: "ssh-unreachable",
|
||||
sshStderr: `host '${host.id}': transport '${host.transport}' is not supported (v0 supports ssh only)`,
|
||||
};
|
||||
}
|
||||
if (argv.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
failedStep: "ssh-unreachable",
|
||||
sshStderr: "internal: empty argv passed to runCrossHostCommand",
|
||||
};
|
||||
}
|
||||
|
||||
const sshOpts: string[] = [];
|
||||
if (host.user) {
|
||||
sshOpts.push("-l", host.user);
|
||||
}
|
||||
const connectTimeout = opts.connectTimeoutSeconds ?? 10;
|
||||
sshOpts.push("-o", `ConnectTimeout=${connectTimeout}`);
|
||||
// No BatchMode — operators may legitimately use password / interactive auth.
|
||||
// Stay deferential to ~/.ssh/config defaults.
|
||||
|
||||
const remoteCommandLine = argv.map(shellQuote).join(" ");
|
||||
const fullArgs = [...sshOpts, host.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
|
||||
if (opts.stdin !== undefined && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
} else if (child.stdin) {
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
});
|
||||
}
|
||||
|
||||
const exitCode: number = await new Promise((resolve) => {
|
||||
child.on("close", (code: number | null) => resolve(code ?? -1));
|
||||
child.on("error", (err: Error) => {
|
||||
stderr += `\n[spawn error] ${err.message}`;
|
||||
resolve(-1);
|
||||
});
|
||||
});
|
||||
|
||||
return classifyResult(exitCode, stdout, stderr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the spawned ssh exit code + stderr signature to a structured result.
|
||||
*
|
||||
* - Exit 0 → success.
|
||||
* - Exit 255 → ssh-layer failure. If stderr matches a known permission/auth
|
||||
* gate (Permission denied, Keychain, host key verification), classify as
|
||||
* `permission-gate` with a hint pointing at the field-note diagnostic.
|
||||
* Otherwise classify as `ssh-unreachable`.
|
||||
* - Any other non-zero → ssh succeeded but the remote rig command failed.
|
||||
* If the remote stderr matches the daemon-not-running signature, classify
|
||||
* as `remote-daemon-unreachable`; otherwise `remote-command-failed`.
|
||||
*/
|
||||
export function classifyResult(exitCode: number, stdout: string, stderr: string): CrossHostResult {
|
||||
if (exitCode === 0) {
|
||||
return { ok: true, failedStep: "none", stdout, stderr, remoteExitCode: 0 };
|
||||
}
|
||||
if (exitCode === 255 || exitCode === -1) {
|
||||
if (looksLikePermissionGate(stderr)) {
|
||||
return {
|
||||
ok: false,
|
||||
failedStep: "permission-gate",
|
||||
sshStderr: stderr,
|
||||
hint: "See openrig-work/field-notes/2026-04-29-l4-3-d6-claude-keychain-over-ssh-diagnostic.md for guidance on Keychain-over-SSH issues.",
|
||||
};
|
||||
}
|
||||
return { ok: false, failedStep: "ssh-unreachable", sshStderr: stderr };
|
||||
}
|
||||
if (looksLikeDaemonUnreachable(stderr) || looksLikeDaemonUnreachable(stdout)) {
|
||||
return {
|
||||
ok: false,
|
||||
failedStep: "remote-daemon-unreachable",
|
||||
stdout,
|
||||
stderr,
|
||||
remoteExitCode: exitCode,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
failedStep: "remote-command-failed",
|
||||
stdout,
|
||||
stderr,
|
||||
remoteExitCode: exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
const PERMISSION_GATE_PATTERNS = [
|
||||
/Permission denied/i,
|
||||
/Host key verification failed/i,
|
||||
/Keychain/i,
|
||||
/Could not request authentication agent/i,
|
||||
/no mutual signature algorithm/i,
|
||||
];
|
||||
|
||||
function looksLikePermissionGate(stderr: string): boolean {
|
||||
return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr));
|
||||
}
|
||||
|
||||
const DAEMON_UNREACHABLE_PATTERNS = [
|
||||
/Daemon not running/i,
|
||||
/Failed to fetch .* from daemon/i,
|
||||
/ECONNREFUSED.*localhost/i,
|
||||
];
|
||||
|
||||
function looksLikeDaemonUnreachable(text: string): boolean {
|
||||
return DAEMON_UNREACHABLE_PATTERNS.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-quote shell escaping. Wraps the input in single quotes, escaping
|
||||
* any embedded single quotes via `'\''`. Safe for passing as a single arg
|
||||
* to a remote POSIX shell.
|
||||
*/
|
||||
export function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
|
||||
|
||||
export interface DaemonState {
|
||||
@@ -21,7 +22,7 @@ export interface DaemonStatus {
|
||||
|
||||
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
|
||||
export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${status.host ?? "127.0.0.1"}:${status.port}`;
|
||||
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
@@ -56,6 +57,7 @@ export const LOG_FILE = path.join(OPENRIG_DIR, "daemon.log");
|
||||
export const LEGACY_STATE_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.json");
|
||||
export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log");
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 7433;
|
||||
const DEFAULT_DB = "openrig.sqlite";
|
||||
const HEALTHZ_RETRIES = 20;
|
||||
@@ -144,7 +146,7 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
|
||||
* - "dead" — PID not alive */
|
||||
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
|
||||
if (!deps.isProcessAlive(state.pid)) return "dead";
|
||||
const host = state.host ?? "127.0.0.1";
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
|
||||
// Any response (ok or not) means something is listening on our port → OpenRig
|
||||
@@ -156,7 +158,70 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredDaemonTarget(): { host: string; port: number } {
|
||||
try {
|
||||
const config = new ConfigStore().resolve();
|
||||
return {
|
||||
host: config.daemon.host,
|
||||
port: config.daemon.port,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
host: DEFAULT_HOST,
|
||||
port: DEFAULT_PORT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrub list: environment variable prefixes and exact names that should NOT
|
||||
* be forwarded from the operator shell into the daemon process. These are
|
||||
* terminal-emulator, GUI-session, and cmux-session variables that can break
|
||||
* adapter initialization when the daemon runs detached.
|
||||
*/
|
||||
const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
|
||||
const ENV_SCRUB_EXACT = new Set([
|
||||
"CMUX_SOCKET_PATH",
|
||||
"CMUX_SURFACE_ID",
|
||||
"CMUX_WORKSPACE",
|
||||
"COLORTERM",
|
||||
"COMMAND_MODE",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
"TERMINFO",
|
||||
]);
|
||||
|
||||
export function buildDaemonEnv(
|
||||
baseEnv: Record<string, string>,
|
||||
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: string },
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (ENV_SCRUB_EXACT.has(key)) continue;
|
||||
if (ENV_SCRUB_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
|
||||
// Explicit OPENRIG_* overrides — always win over inherited values
|
||||
env["OPENRIG_PORT"] = String(opts.port);
|
||||
env["OPENRIG_HOST"] = opts.host;
|
||||
env["OPENRIG_DB"] = opts.db;
|
||||
if (opts.transcriptsEnabled !== undefined) {
|
||||
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
|
||||
}
|
||||
if (opts.transcriptsPath) {
|
||||
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const host = opts.host ?? DEFAULT_HOST;
|
||||
const db = opts.db ?? DEFAULT_DB;
|
||||
|
||||
// Check if already running
|
||||
const existing = readState(deps);
|
||||
if (existing) {
|
||||
@@ -169,11 +234,20 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
throw new Error(`Existing daemon process (pid ${existing.pid} on port ${existing.port}) is unresponsive — recover it before starting a new daemon.`);
|
||||
}
|
||||
// "dead" or "not_rigged" → stale state, safe to proceed
|
||||
} else {
|
||||
let recoveredRunning = false;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
|
||||
recoveredRunning = true;
|
||||
} catch (err) {
|
||||
if (err instanceof HealthProbeTimeoutError) {
|
||||
throw new Error(`Daemon on port ${port} is unresponsive, and daemon state is missing — recover it before starting a new daemon.`);
|
||||
}
|
||||
}
|
||||
if (recoveredRunning) {
|
||||
throw new Error(`Daemon already running on port ${port}, but daemon state is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const host = opts.host ?? "127.0.0.1";
|
||||
const db = opts.db ?? DEFAULT_DB;
|
||||
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
|
||||
|
||||
deps.mkdirp(OPENRIG_DIR);
|
||||
@@ -181,14 +255,13 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
const logFd = deps.openForAppend(LOG_FILE);
|
||||
|
||||
const child = deps.spawn(process.execPath, [daemonEntry], {
|
||||
env: {
|
||||
...process.env as Record<string, string>,
|
||||
OPENRIG_PORT: String(port),
|
||||
OPENRIG_HOST: host,
|
||||
OPENRIG_DB: db,
|
||||
...(opts.transcriptsEnabled !== undefined ? { OPENRIG_TRANSCRIPTS_ENABLED: String(opts.transcriptsEnabled) } : {}),
|
||||
...(opts.transcriptsPath ? { OPENRIG_TRANSCRIPTS_PATH: opts.transcriptsPath } : {}),
|
||||
},
|
||||
env: buildDaemonEnv(process.env as Record<string, string>, {
|
||||
port,
|
||||
host,
|
||||
db,
|
||||
transcriptsEnabled: opts.transcriptsEnabled,
|
||||
transcriptsPath: opts.transcriptsPath,
|
||||
}),
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
detached: true,
|
||||
});
|
||||
@@ -233,7 +306,22 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
|
||||
export async function stopDaemon(deps: LifecycleDeps): Promise<void> {
|
||||
const state = readState(deps);
|
||||
if (!state) return; // Not running — clean exit
|
||||
if (!state) {
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
let recoveredRunning = false;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
|
||||
recoveredRunning = true;
|
||||
} catch (err) {
|
||||
if (err instanceof HealthProbeTimeoutError) {
|
||||
throw new Error(`Daemon state is missing and port ${configured.port} is unresponsive — cannot stop safely.`);
|
||||
}
|
||||
}
|
||||
if (recoveredRunning) {
|
||||
throw new Error(`Daemon is running on port ${configured.port}, but daemon state is missing — cannot stop safely.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
|
||||
const pidState = await checkPid(state, deps);
|
||||
@@ -275,14 +363,27 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
|
||||
try {
|
||||
const res = await fetchDaemonProbe(deps, `${openrigUrl}/healthz`);
|
||||
const url = new URL(openrigUrl);
|
||||
return { state: "running", port: Number(url.port) || 7433, host: url.hostname || "127.0.0.1", healthy: res.ok };
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: res.ok };
|
||||
} catch {
|
||||
return { state: "stopped" };
|
||||
}
|
||||
}
|
||||
|
||||
const state = readState(deps);
|
||||
if (!state) return { state: "stopped" };
|
||||
if (!state) {
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
try {
|
||||
const res = await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
|
||||
return {
|
||||
state: "running",
|
||||
port: configured.port,
|
||||
host: configured.host,
|
||||
healthy: res.ok,
|
||||
};
|
||||
} catch {
|
||||
return { state: "stopped" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!deps.isProcessAlive(state.pid)) {
|
||||
// Process dead — stale state
|
||||
@@ -291,7 +392,7 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
|
||||
}
|
||||
|
||||
// Process alive — check healthz
|
||||
const host = state.host ?? "127.0.0.1";
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
let healthy = false;
|
||||
try {
|
||||
const res = await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export const DESTROY_CONFIRM_TOKEN = "destroy-openrig-state";
|
||||
|
||||
export type DestroyScope = "state" | "all";
|
||||
|
||||
export interface DestroyRuntimeConfig {
|
||||
stateRoot: string;
|
||||
dbPath: string;
|
||||
transcriptsPath: string;
|
||||
daemonHost: string;
|
||||
daemonPort: number;
|
||||
}
|
||||
|
||||
export interface DestroyTarget {
|
||||
path: string;
|
||||
kind: "state_root" | "db_file" | "db_shm" | "db_wal" | "transcripts_dir";
|
||||
backupPath?: string;
|
||||
}
|
||||
|
||||
export interface DestroyPlan {
|
||||
scope: DestroyScope;
|
||||
backup: boolean;
|
||||
stateRoot: string;
|
||||
daemonHost: string;
|
||||
daemonPort: number;
|
||||
managedTmuxSessions: string[];
|
||||
targets: DestroyTarget[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DestroyResult {
|
||||
scope: DestroyScope;
|
||||
backup: boolean;
|
||||
stateRoot: string;
|
||||
backupPaths: string[];
|
||||
daemonStopped: boolean;
|
||||
portCleared: boolean;
|
||||
stateRecreated: boolean;
|
||||
tmuxKilled: number;
|
||||
tmuxMissing: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ListenerInspection {
|
||||
kind: "openrig" | "other_http" | "unreachable";
|
||||
healthy?: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DestroyDeps {
|
||||
stopDaemon: () => Promise<void>;
|
||||
inspectListener: (host: string, port: number) => Promise<ListenerInspection>;
|
||||
findListeningPid: (port: number) => number | null;
|
||||
killProcess: (pid: number) => void;
|
||||
exists: (path: string) => boolean;
|
||||
renamePath: (from: string, to: string) => void;
|
||||
removePath: (path: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
listManagedTmuxSessions: (dbPath: string) => string[];
|
||||
killTmuxSession: (sessionName: string) => boolean;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => Date;
|
||||
}
|
||||
|
||||
function normalizePathForPrefix(path: string): string {
|
||||
return path.endsWith("/") ? path : `${path}/`;
|
||||
}
|
||||
|
||||
export function isWithinPath(candidate: string, root: string): boolean {
|
||||
if (candidate === root) return true;
|
||||
return candidate.startsWith(normalizePathForPrefix(root));
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const yyyy = String(date.getFullYear());
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
const ss = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${yyyy}${mm}${dd}-${hh}${min}${ss}`;
|
||||
}
|
||||
|
||||
export function buildBackupPath(targetPath: string, exists: (path: string) => boolean, now = new Date()): string {
|
||||
const stamp = formatTimestamp(now);
|
||||
const base = `${targetPath}.backup-${stamp}`;
|
||||
if (!exists(base)) return base;
|
||||
let suffix = 2;
|
||||
while (exists(`${base}-${suffix}`)) suffix += 1;
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
export function listManagedTmuxSessionsFromDb(dbPath: string): string[] {
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
COALESCE(
|
||||
b.tmux_session,
|
||||
(
|
||||
SELECT s2.session_name
|
||||
FROM sessions s2
|
||||
WHERE s2.node_id = n.id
|
||||
ORDER BY datetime(s2.created_at) DESC, s2.rowid DESC
|
||||
LIMIT 1
|
||||
)
|
||||
) AS session_name
|
||||
FROM nodes n
|
||||
LEFT JOIN bindings b ON b.node_id = n.id
|
||||
WHERE COALESCE(b.attachment_type, 'tmux') = 'tmux'
|
||||
`).all() as Array<{ session_name: string | null }>;
|
||||
|
||||
return rows
|
||||
.map((row) => row.session_name?.trim() ?? "")
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.sort();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDestroyPlan(
|
||||
scope: DestroyScope,
|
||||
backup: boolean,
|
||||
config: DestroyRuntimeConfig,
|
||||
deps: Pick<DestroyDeps, "exists" | "listManagedTmuxSessions" | "now">
|
||||
): DestroyPlan {
|
||||
const warnings: string[] = [];
|
||||
const targets: DestroyTarget[] = [];
|
||||
|
||||
const stateRootExists = deps.exists(config.stateRoot);
|
||||
targets.push({
|
||||
path: config.stateRoot,
|
||||
kind: "state_root",
|
||||
...(backup && stateRootExists ? { backupPath: buildBackupPath(config.stateRoot, deps.exists, deps.now()) } : {}),
|
||||
});
|
||||
|
||||
const addExternalTarget = (path: string, kind: DestroyTarget["kind"]) => {
|
||||
if (!deps.exists(path)) return;
|
||||
if (isWithinPath(path, config.stateRoot)) return;
|
||||
targets.push({
|
||||
path,
|
||||
kind,
|
||||
...(backup ? { backupPath: buildBackupPath(path, deps.exists, deps.now()) } : {}),
|
||||
});
|
||||
warnings.push(`${kind} is outside state root and will be ${backup ? "backed up separately" : "deleted separately"}: ${path}`);
|
||||
};
|
||||
|
||||
addExternalTarget(config.dbPath, "db_file");
|
||||
addExternalTarget(`${config.dbPath}-shm`, "db_shm");
|
||||
addExternalTarget(`${config.dbPath}-wal`, "db_wal");
|
||||
addExternalTarget(config.transcriptsPath, "transcripts_dir");
|
||||
|
||||
let managedTmuxSessions: string[] = [];
|
||||
if (scope === "all" && deps.exists(config.dbPath)) {
|
||||
try {
|
||||
managedTmuxSessions = deps.listManagedTmuxSessions(config.dbPath);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to enumerate managed tmux sessions from ${config.dbPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scope,
|
||||
backup,
|
||||
stateRoot: config.stateRoot,
|
||||
daemonHost: config.daemonHost,
|
||||
daemonPort: config.daemonPort,
|
||||
managedTmuxSessions,
|
||||
targets,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "0.0.0.0";
|
||||
}
|
||||
|
||||
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
|
||||
const warnings = [...plan.warnings];
|
||||
const backupPaths: string[] = [];
|
||||
let stateRecreated = false;
|
||||
|
||||
try {
|
||||
await deps.stopDaemon();
|
||||
} catch (err) {
|
||||
warnings.push(`stopDaemon failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
let daemonStopped = false;
|
||||
let portCleared = false;
|
||||
|
||||
if (isLocalHost(plan.daemonHost)) {
|
||||
const before = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
|
||||
if (before.kind === "openrig") {
|
||||
const listenerPid = deps.findListeningPid(plan.daemonPort);
|
||||
if (listenerPid !== null) {
|
||||
try {
|
||||
deps.killProcess(listenerPid);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to terminate listener pid ${listenerPid}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if ((await deps.inspectListener(plan.daemonHost, plan.daemonPort)).kind === "unreachable") break;
|
||||
await deps.sleep(100);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`OpenRig responded on port ${plan.daemonPort}, but no listener pid was found.`);
|
||||
}
|
||||
} else if (before.kind === "other_http") {
|
||||
warnings.push(`Port ${plan.daemonPort} is occupied by a live non-OpenRig HTTP listener. State was left untouched.`);
|
||||
}
|
||||
|
||||
const after = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
|
||||
portCleared = after.kind === "unreachable";
|
||||
daemonStopped = portCleared;
|
||||
} else {
|
||||
warnings.push(`Daemon host ${plan.daemonHost} is not local. rig destroy only operates on local daemon targets; state was left untouched.`);
|
||||
}
|
||||
|
||||
if (!portCleared) {
|
||||
return {
|
||||
scope: plan.scope,
|
||||
backup: plan.backup,
|
||||
stateRoot: plan.stateRoot,
|
||||
backupPaths,
|
||||
daemonStopped,
|
||||
portCleared,
|
||||
stateRecreated,
|
||||
tmuxKilled: 0,
|
||||
tmuxMissing: 0,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let tmuxKilled = 0;
|
||||
let tmuxMissing = 0;
|
||||
if (plan.scope === "all") {
|
||||
for (const sessionName of plan.managedTmuxSessions) {
|
||||
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
|
||||
else tmuxMissing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of plan.targets) {
|
||||
if (!deps.exists(target.path)) continue;
|
||||
if (plan.backup && target.backupPath) {
|
||||
deps.renamePath(target.path, target.backupPath);
|
||||
backupPaths.push(target.backupPath);
|
||||
} else {
|
||||
deps.removePath(target.path);
|
||||
}
|
||||
}
|
||||
|
||||
deps.mkdirp(plan.stateRoot);
|
||||
stateRecreated = true;
|
||||
|
||||
return {
|
||||
scope: plan.scope,
|
||||
backup: plan.backup,
|
||||
stateRoot: plan.stateRoot,
|
||||
backupPaths,
|
||||
daemonStopped,
|
||||
portCleared,
|
||||
stateRecreated,
|
||||
tmuxKilled,
|
||||
tmuxMissing,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function findListeningPidWithLsof(port: number): number | null {
|
||||
try {
|
||||
const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf-8" }).trim();
|
||||
if (!output) return null;
|
||||
const first = output.split(/\s+/)[0]?.trim();
|
||||
if (!first) return null;
|
||||
const pid = parseInt(first, 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function killTmuxSessionWithCli(sessionName: string): boolean {
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { getDefaultOpenRigPath } from "./openrig-compat.js";
|
||||
|
||||
/**
|
||||
* A single declared host in `~/.openrig/hosts.yaml`. v0 supports `transport: ssh`
|
||||
* only. `target` is whatever ssh resolves (DNS, SSH config alias, IP, etc.).
|
||||
*/
|
||||
export interface HostEntry {
|
||||
id: string;
|
||||
transport: "ssh";
|
||||
target: string;
|
||||
user?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface HostRegistry {
|
||||
hosts: HostEntry[];
|
||||
}
|
||||
|
||||
export type HostRegistryLoadResult =
|
||||
| { ok: true; registry: HostRegistry }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type HostResolution =
|
||||
| { ok: true; host: HostEntry }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const KNOWN_TRANSPORTS = new Set(["ssh"]);
|
||||
|
||||
export function defaultHostRegistryPath(): string {
|
||||
return getDefaultOpenRigPath("hosts.yaml");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate the host registry from disk. v0 file shape:
|
||||
*
|
||||
* hosts:
|
||||
* - id: vm-claude-test
|
||||
* transport: ssh
|
||||
* target: vm-claude-test.local
|
||||
* user: wrandom # optional
|
||||
* notes: "Tart VM" # optional
|
||||
*
|
||||
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
|
||||
* file returns a clear error pointing at the canonical path.
|
||||
*/
|
||||
export function loadHostRegistry(path: string = defaultHostRegistryPath()): HostRegistryLoadResult {
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `host registry not found at ${path}. Create it with a 'hosts:' array of { id, transport, target, [user] } entries; v0 supports transport: ssh only.`,
|
||||
};
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf-8");
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to read host registry at ${path}: ${(err as Error).message}` };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(raw);
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to parse host registry YAML at ${path}: ${(err as Error).message}` };
|
||||
}
|
||||
return validateHostRegistry(parsed, path);
|
||||
}
|
||||
|
||||
export function validateHostRegistry(parsed: unknown, sourcePath: string): HostRegistryLoadResult {
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return { ok: false, error: `host registry at ${sourcePath} must be a YAML object with a 'hosts' array` };
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const hosts = obj["hosts"];
|
||||
if (!Array.isArray(hosts)) {
|
||||
return { ok: false, error: `host registry at ${sourcePath}: 'hosts' must be an array` };
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const validated: HostEntry[] = [];
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
const raw = hosts[i];
|
||||
const prefix = `host registry at ${sourcePath}: hosts[${i}]`;
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return { ok: false, error: `${prefix}: must be an object with id/transport/target` };
|
||||
}
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const id = entry["id"];
|
||||
if (typeof id !== "string" || id.trim() === "") {
|
||||
return { ok: false, error: `${prefix}.id: required non-empty string` };
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` };
|
||||
}
|
||||
seenIds.add(id);
|
||||
const transport = entry["transport"];
|
||||
if (transport !== "ssh") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${prefix}.transport: v0 supports 'ssh' only (got ${JSON.stringify(transport)}); declared known transports: ${[...KNOWN_TRANSPORTS].sort().join(", ")}`,
|
||||
};
|
||||
}
|
||||
const target = entry["target"];
|
||||
if (typeof target !== "string" || target.trim() === "") {
|
||||
return { ok: false, error: `${prefix}.target: required non-empty string (an ssh target — DNS name, SSH config alias, or IP)` };
|
||||
}
|
||||
const user = entry["user"];
|
||||
if (user !== undefined && (typeof user !== "string" || user.trim() === "")) {
|
||||
return { ok: false, error: `${prefix}.user: optional, but if present must be a non-empty string` };
|
||||
}
|
||||
const notes = entry["notes"];
|
||||
if (notes !== undefined && typeof notes !== "string") {
|
||||
return { ok: false, error: `${prefix}.notes: optional, but if present must be a string` };
|
||||
}
|
||||
validated.push({
|
||||
id,
|
||||
transport: "ssh",
|
||||
target,
|
||||
...(user !== undefined ? { user: user as string } : {}),
|
||||
...(notes !== undefined ? { notes: notes as string } : {}),
|
||||
});
|
||||
}
|
||||
return { ok: true, registry: { hosts: validated } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a host id against a loaded registry. Unknown id returns an error
|
||||
* naming the requested id and listing up to 10 known ids for discoverability.
|
||||
*/
|
||||
export function resolveHost(registry: HostRegistry, id: string): HostResolution {
|
||||
const match = registry.hosts.find((h) => h.id === id);
|
||||
if (match) return { ok: true, host: match };
|
||||
const knownIds = registry.hosts.map((h) => h.id).slice(0, 10);
|
||||
const idsHint = knownIds.length > 0
|
||||
? ` Known host ids: ${knownIds.join(", ")}${registry.hosts.length > knownIds.length ? ` (+${registry.hosts.length - knownIds.length} more)` : ""}.`
|
||||
: " (registry is empty)";
|
||||
return {
|
||||
ok: false,
|
||||
error: `unknown host id '${id}'.${idsHint}`,
|
||||
};
|
||||
}
|
||||
@@ -25,12 +25,19 @@ import { agentCommand, type AgentDeps } from "./commands/agent.js";
|
||||
import { rigCommand, type RigDeps } from "./commands/rig.js";
|
||||
import { transcriptCommand } from "./commands/transcript.js";
|
||||
import { sendCommand } from "./commands/send.js";
|
||||
import { streamCommand, type StreamDeps } from "./commands/stream.js";
|
||||
import { queueCommand, type QueueDeps } from "./commands/queue.js";
|
||||
import { projectCommand, type ProjectDeps } from "./commands/project.js";
|
||||
import { viewCommand, type ViewDeps } from "./commands/view.js";
|
||||
import { watchdogCommand, type WatchdogDeps } from "./commands/watchdog.js";
|
||||
import { workflowCommand, type WorkflowDeps } from "./commands/workflow.js";
|
||||
import { captureCommand } from "./commands/capture.js";
|
||||
import { broadcastCommand } from "./commands/broadcast.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
import { preflightCommand } from "./commands/preflight.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { expandCommand } from "./commands/expand.js";
|
||||
import { envCommand } from "./commands/env.js";
|
||||
import { askCommand } from "./commands/ask.js";
|
||||
import { chatroomCommand } from "./commands/chatroom.js";
|
||||
import { specsCommand } from "./commands/specs.js";
|
||||
@@ -40,6 +47,14 @@ import { releaseCommand } from "./commands/release.js";
|
||||
import { launchCommand } from "./commands/launch.js";
|
||||
import { removeCommand } from "./commands/remove.js";
|
||||
import { shrinkCommand } from "./commands/shrink.js";
|
||||
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import { contextCommand } from "./commands/context.js";
|
||||
import { restoreCheckCommand } from "./commands/restore-check.js";
|
||||
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -67,6 +82,12 @@ export interface ProgramDeps {
|
||||
rigDeps?: RigDeps;
|
||||
transcriptDeps?: StatusDeps;
|
||||
sendDeps?: StatusDeps;
|
||||
streamDeps?: StreamDeps;
|
||||
queueDeps?: QueueDeps;
|
||||
projectDeps?: ProjectDeps;
|
||||
viewDeps?: ViewDeps;
|
||||
watchdogDeps?: WatchdogDeps;
|
||||
workflowDeps?: WorkflowDeps;
|
||||
captureDeps?: StatusDeps;
|
||||
broadcastDeps?: StatusDeps;
|
||||
askDeps?: StatusDeps;
|
||||
@@ -74,11 +95,17 @@ export interface ProgramDeps {
|
||||
specsDeps?: StatusDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
expandDeps?: StatusDeps;
|
||||
envDeps?: StatusDeps;
|
||||
unclaimDeps?: StatusDeps;
|
||||
releaseDeps?: StatusDeps;
|
||||
launchDeps?: StatusDeps;
|
||||
removeDeps?: StatusDeps;
|
||||
shrinkDeps?: StatusDeps;
|
||||
destroyDeps?: DestroyCommandDeps;
|
||||
restorePacketDeps?: RestorePacketDeps;
|
||||
compactPlanDeps?: CompactPlanDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
@@ -113,6 +140,12 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(rigCommand(depsOverride?.rigDeps));
|
||||
program.addCommand(transcriptCommand(depsOverride?.transcriptDeps));
|
||||
program.addCommand(sendCommand(depsOverride?.sendDeps));
|
||||
program.addCommand(streamCommand(depsOverride?.streamDeps));
|
||||
program.addCommand(queueCommand(depsOverride?.queueDeps));
|
||||
program.addCommand(projectCommand(depsOverride?.projectDeps));
|
||||
program.addCommand(viewCommand(depsOverride?.viewDeps));
|
||||
program.addCommand(watchdogCommand(depsOverride?.watchdogDeps));
|
||||
program.addCommand(workflowCommand(depsOverride?.workflowDeps));
|
||||
program.addCommand(captureCommand(depsOverride?.captureDeps));
|
||||
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
@@ -123,11 +156,20 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(preflightCommand());
|
||||
program.addCommand(doctorCommand());
|
||||
program.addCommand(expandCommand(depsOverride?.expandDeps));
|
||||
program.addCommand(envCommand(depsOverride?.envDeps));
|
||||
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
|
||||
program.addCommand(releaseCommand(depsOverride?.releaseDeps));
|
||||
program.addCommand(launchCommand(depsOverride?.launchDeps));
|
||||
program.addCommand(removeCommand(depsOverride?.removeDeps));
|
||||
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
|
||||
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
|
||||
program.addCommand(setupCommand());
|
||||
program.addCommand(contextCommand());
|
||||
program.addCommand(restoreCheckCommand());
|
||||
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
|
||||
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { z } from "zod";
|
||||
import type { DaemonClient, DaemonResponse } from "./client.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
|
||||
|
||||
type TextResult = {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: true;
|
||||
@@ -64,7 +67,11 @@ export function createMcpServer(client: DaemonClient): McpServer {
|
||||
},
|
||||
async ({ sourceRef, plan, autoApprove, targetRoot }) => {
|
||||
try {
|
||||
const res = await client.post("/api/up", { sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot });
|
||||
const res = await client.post(
|
||||
"/api/up",
|
||||
{ sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot },
|
||||
plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
|
||||
);
|
||||
return mapResult(res);
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
|
||||
@@ -302,10 +309,24 @@ export function createMcpServer(client: DaemonClient): McpServer {
|
||||
text: z.string().describe("Message text to send"),
|
||||
verify: z.boolean().optional().describe("Verify delivery by checking pane content"),
|
||||
force: z.boolean().optional().describe("Send even if target appears mid-task"),
|
||||
waitForIdleSeconds: z.number().positive().optional().describe("Wait until explicit idle evidence before sending"),
|
||||
},
|
||||
async ({ session, text, verify, force }) => {
|
||||
async ({ session, text, verify, force, waitForIdleSeconds }) => {
|
||||
try {
|
||||
const res = await client.post("/api/transport/send", { session, text, verify, force });
|
||||
if (force && waitForIdleSeconds !== undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ ok: false, error: "waitForIdleSeconds cannot be combined with force" }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const waitForIdleMs = waitForIdleSeconds === undefined ? undefined : Math.ceil(waitForIdleSeconds * 1000);
|
||||
const res = await client.post("/api/transport/send", {
|
||||
session,
|
||||
text,
|
||||
verify,
|
||||
force,
|
||||
waitForIdleMs,
|
||||
}, waitForIdleRequestOptions(waitForIdleMs));
|
||||
return mapResult(res);
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
|
||||
@@ -403,3 +424,8 @@ export function createMcpServer(client: DaemonClient): McpServer {
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
|
||||
if (waitForIdleMs === undefined) return undefined;
|
||||
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const warnedKeys = new Set<string>();
|
||||
|
||||
export function getOpenRigHome(): string {
|
||||
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME");
|
||||
if (configured !== undefined) return configured;
|
||||
return join(homedir(), ".openrig");
|
||||
}
|
||||
|
||||
@@ -13,8 +17,6 @@ export function getLegacyRiggedHome(): string {
|
||||
export const OPENRIG_HOME = getOpenRigHome();
|
||||
export const LEGACY_RIGGED_HOME = getLegacyRiggedHome();
|
||||
|
||||
const warnedKeys = new Set<string>();
|
||||
|
||||
function warnOnce(key: string, message: string): void {
|
||||
if (warnedKeys.has(key)) return;
|
||||
warnedKeys.add(key);
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// claude-transcript-parser.ts — parses Claude transcript JSONL into the
|
||||
// same StructuredTranscript shape as the Codex parser.
|
||||
//
|
||||
// NEW work for the Restore-Packet vertical M2b. No prior art. Inspected
|
||||
// host's Claude transcript files at ~/.claude/projects/<project>/<id>.jsonl.
|
||||
//
|
||||
// Claude JSONL semantics (observed):
|
||||
// - First few lines may carry meta records: { type, customTitle, sessionId }
|
||||
// or { type, agentName, sessionId } or { type, permissionMode, sessionId }.
|
||||
// - Message records have type: "user" | "assistant" with fields:
|
||||
// { type, message, cwd, timestamp, parentUuid, sessionId, ... }.
|
||||
// `message` is an object whose shape is provider-API-specific:
|
||||
// - user: { role: "user", content: string | array }
|
||||
// - assistant: { role: "assistant", content: array of { type: "text", text } }
|
||||
// - Attachment records ({ type: "attachment", attachment, ... }) carry tool
|
||||
// input/output content; mapped to raw_tool_outputs by the omitted-records
|
||||
// classifier and skipped from the message stream.
|
||||
// - Other types ({ type: "summary" }, sessionId-only meta records, etc.)
|
||||
// are mapped to reasoning_records.
|
||||
|
||||
import { redact, hasSecretPattern } from "./redaction.js";
|
||||
import { classifyClaudeRecord, OmittedCounter } from "./omitted-records.js";
|
||||
import type {
|
||||
ExtractedMessage,
|
||||
PathCount,
|
||||
StructuredTranscript,
|
||||
TypeCounts,
|
||||
SessionMeta,
|
||||
} from "./types.js";
|
||||
|
||||
const PATH_PATTERNS: readonly RegExp[] = [
|
||||
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
|
||||
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
|
||||
];
|
||||
|
||||
function extractPaths(text: string, counts: Map<string, number>): void {
|
||||
for (const pattern of PATH_PATTERNS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const cleaned = match[0].replace(/[),.;\]"'`]+$/g, "");
|
||||
if (cleaned.length < 6) continue;
|
||||
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.trim());
|
||||
if (!line) return "";
|
||||
return line.trim().slice(0, 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of walking a Claude `message.content` field. Returns:
|
||||
* - `text`: concatenated user-visible text from `{ type: "text" }` parts.
|
||||
* - `toolUseCount`: number of `{ type: "tool_use" }` parts seen.
|
||||
* These are nested function-call records inside an assistant turn;
|
||||
* per M1 contract § 5 they map to `function_call_output`.
|
||||
* - `toolResultCount`: number of `{ type: "tool_result" }` parts seen.
|
||||
* These are nested tool-output records inside a user turn; per
|
||||
* contract § 5 they map to `raw_tool_outputs`.
|
||||
* - `toolPaths`: paths extracted from omitted tool_use inputs and
|
||||
* tool_result content (mirrors `codex-jsonl-parser.ts`'s tool-call
|
||||
* path-inventory behavior; the tool record itself is NOT in the
|
||||
* message text but its file references stay in the touched-files
|
||||
* inventory).
|
||||
*
|
||||
* R2 fix (per M2b R2 dispatch qitem-20260502011841-fb53d7fd): inspect
|
||||
* nested content parts BEFORE discarding non-text. Previously dropped
|
||||
* tool_use / tool_result silently, leading to misleading all-zero
|
||||
* omittedCounts on real Claude transcripts where nearly every assistant
|
||||
* turn has tool_use and most user turns have tool_result.
|
||||
*/
|
||||
interface ContentWalkResult {
|
||||
text: string;
|
||||
toolUseCount: number;
|
||||
toolResultCount: number;
|
||||
toolPaths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `text` candidates from a tool_result `content` field. The
|
||||
* Claude API allows two shapes for `tool_result.content`:
|
||||
* - string (the most common; e.g., a command's stdout): use directly.
|
||||
* - array of part objects (when the tool returned structured content):
|
||||
* collect each part's `text` field.
|
||||
* Returns a flat string suitable for path extraction.
|
||||
*/
|
||||
function toolResultText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
parts.push(part);
|
||||
} else if (part && typeof part === "object") {
|
||||
const p = part as Record<string, unknown>;
|
||||
if (p.type === "text" && typeof p.text === "string") {
|
||||
parts.push(p.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function walkClaudeContent(message: unknown): ContentWalkResult {
|
||||
const out: ContentWalkResult = { text: "", toolUseCount: 0, toolResultCount: 0, toolPaths: [] };
|
||||
if (!message || typeof message !== "object") return out;
|
||||
const obj = message as Record<string, unknown>;
|
||||
const content = obj.content;
|
||||
if (typeof content === "string") {
|
||||
out.text = content;
|
||||
return out;
|
||||
}
|
||||
if (!Array.isArray(content)) return out;
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
textParts.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const p = part as Record<string, unknown>;
|
||||
const partType = typeof p.type === "string" ? p.type : "";
|
||||
|
||||
if (partType === "text" && typeof p.text === "string") {
|
||||
textParts.push(p.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === "tool_use") {
|
||||
out.toolUseCount += 1;
|
||||
// Extract paths from the tool_use input. Mirrors the Codex parser's
|
||||
// tool-call path-inventory behavior at codex-jsonl-parser.ts.
|
||||
const input = p.input;
|
||||
const inputStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
|
||||
out.toolPaths.push(inputStr);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === "tool_result") {
|
||||
out.toolResultCount += 1;
|
||||
// tool_result.content can be string OR array; both extract a flat
|
||||
// text body for path scanning.
|
||||
const resultText = toolResultText(p.content);
|
||||
out.toolPaths.push(resultText);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other part types (image, etc.) are not user-visible text and not
|
||||
// counted in the M1 contract enums; skip without counting.
|
||||
}
|
||||
out.text = textParts.filter((s) => s.length > 0).join("\n");
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ClaudeRecord {
|
||||
type?: unknown;
|
||||
timestamp?: unknown;
|
||||
message?: unknown;
|
||||
cwd?: unknown;
|
||||
sessionId?: unknown;
|
||||
parentUuid?: unknown;
|
||||
attachment?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Claude transcript JSONL string into a StructuredTranscript.
|
||||
* Pure function; no I/O.
|
||||
*
|
||||
* Output is the SAME shape as the Codex parser per the M2b dispatch's
|
||||
* "emit the same structured representation as the Codex parser"
|
||||
* requirement. Downstream modules (packet-writer, redaction-counter)
|
||||
* see no runtime difference.
|
||||
*/
|
||||
export function parseClaudeTranscript(content: string): StructuredTranscript {
|
||||
const messages: ExtractedMessage[] = [];
|
||||
const pathCounts = new Map<string, number>();
|
||||
const typeCounts: TypeCounts = {};
|
||||
const omittedCounter = new OmittedCounter();
|
||||
let sessionMeta: SessionMeta | null = null;
|
||||
let lineCount = 0;
|
||||
let messageCount = 0;
|
||||
// Claude transcripts don't have "compacted" records; field stays at 0.
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
if (!rawLine.trim()) continue;
|
||||
lineCount++;
|
||||
|
||||
let record: ClaudeRecord;
|
||||
try {
|
||||
record = JSON.parse(rawLine) as ClaudeRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recordType = typeof record.type === "string" ? record.type : "";
|
||||
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
|
||||
|
||||
// Capture sessionMeta from the first record that carries cwd + sessionId.
|
||||
if (
|
||||
sessionMeta === null &&
|
||||
typeof record.cwd === "string" &&
|
||||
typeof record.sessionId === "string"
|
||||
) {
|
||||
sessionMeta = {
|
||||
cwd: record.cwd,
|
||||
sessionId: record.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
const classification = classifyClaudeRecord(record);
|
||||
if (classification.kind === "omitted") {
|
||||
omittedCounter.recordOmission(classification.reason);
|
||||
// For attachment records, walk the attachment payload for paths
|
||||
// (mirrors the Codex parser's tool-call path-extraction behavior).
|
||||
if (recordType === "attachment" && record.attachment) {
|
||||
const attachStr = typeof record.attachment === "string"
|
||||
? record.attachment
|
||||
: JSON.stringify(record.attachment);
|
||||
extractPaths(attachStr, pathCounts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kept message.
|
||||
const role: "user" | "assistant" = recordType === "user" ? "user" : "assistant";
|
||||
const walked = walkClaudeContent(record.message);
|
||||
|
||||
// R2 fix: count nested tool_use / tool_result parts BEFORE deciding
|
||||
// whether to emit a visible-text message. These counters fire
|
||||
// regardless of whether the record contributes a message to the
|
||||
// visible transcript — capturing them here is what M1 contract § 5
|
||||
// requires for an honest `omitted_classes` field downstream.
|
||||
for (let i = 0; i < walked.toolUseCount; i++) {
|
||||
omittedCounter.recordOmission("function_call_output");
|
||||
}
|
||||
for (let i = 0; i < walked.toolResultCount; i++) {
|
||||
omittedCounter.recordOmission("raw_tool_outputs");
|
||||
}
|
||||
// Path extraction from omitted tool parts (mirrors Codex parser
|
||||
// behavior of walking tool-call args / inputs for path inventory
|
||||
// even though the records themselves are omitted from messages).
|
||||
for (const toolText of walked.toolPaths) {
|
||||
extractPaths(toolText, pathCounts);
|
||||
}
|
||||
|
||||
if (hasSecretPattern(walked.text)) {
|
||||
omittedCounter.recordRedaction();
|
||||
}
|
||||
const text = redact(walked.text).trim();
|
||||
if (!text) continue;
|
||||
|
||||
messageCount++;
|
||||
extractPaths(text, pathCounts);
|
||||
messages.push({
|
||||
timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
|
||||
role,
|
||||
text,
|
||||
preview: firstLine(text),
|
||||
});
|
||||
}
|
||||
|
||||
const paths: PathCount[] = [...pathCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 200)
|
||||
.map(([path, count]) => ({ path, count }));
|
||||
|
||||
return {
|
||||
sessionMeta,
|
||||
lineCount,
|
||||
messageCount,
|
||||
compactedCount: 0,
|
||||
typeCounts,
|
||||
omittedCounts: omittedCounter.counts,
|
||||
messages,
|
||||
paths,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// codex-jsonl-parser.ts — parses Codex JSONL into a StructuredTranscript.
|
||||
//
|
||||
// Translates the Velocity prior art parser at
|
||||
// field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/
|
||||
// codex-jsonl-to-restore-packet.mjs:81-160 from JS to TypeScript.
|
||||
// Velocity .mjs is FROZEN reference; this module borrows the structural
|
||||
// shape only.
|
||||
//
|
||||
// Codex JSONL semantics (from Velocity prior art):
|
||||
// - record.type === "session_meta" → session metadata (cwd, etc.)
|
||||
// - record.type === "compacted" → compaction marker (counter only)
|
||||
// - record.type === "response_item" + payload.type:
|
||||
// - "function_call" → omitted (function_call_output class); extract paths from args
|
||||
// - "custom_tool_call" → omitted (raw_tool_outputs class); extract paths from input
|
||||
// - "reasoning" → omitted (reasoning_records class)
|
||||
// - "message" → kept (after redaction); extract paths from content
|
||||
//
|
||||
// Output is a StructuredTranscript (per types.ts) consumed by the
|
||||
// packet-writer (M2c).
|
||||
|
||||
import { redact, hasSecretPattern } from "./redaction.js";
|
||||
import { classifyCodexRecord, OmittedCounter } from "./omitted-records.js";
|
||||
import type {
|
||||
ExtractedMessage,
|
||||
PathCount,
|
||||
StructuredTranscript,
|
||||
TypeCounts,
|
||||
SessionMeta,
|
||||
} from "./types.js";
|
||||
|
||||
const PATH_PATTERNS: readonly RegExp[] = [
|
||||
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
|
||||
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
|
||||
];
|
||||
|
||||
function textFromContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const obj = part as Record<string, unknown>;
|
||||
const val = obj.text ?? obj.content ?? obj.output ?? obj.input ?? "";
|
||||
return typeof val === "string" ? val : "";
|
||||
})
|
||||
.filter((s) => s.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractPaths(text: string, counts: Map<string, number>): void {
|
||||
for (const pattern of PATH_PATTERNS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const cleaned = match[0].replace(/[),.;\]"'`]+$/g, "");
|
||||
if (cleaned.length < 6) continue;
|
||||
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.trim());
|
||||
if (!line) return "";
|
||||
return line.trim().slice(0, 180);
|
||||
}
|
||||
|
||||
interface CodexRecord {
|
||||
type?: unknown;
|
||||
timestamp?: unknown;
|
||||
payload?: {
|
||||
type?: unknown;
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
arguments?: unknown;
|
||||
input?: unknown;
|
||||
cwd?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Codex JSONL string (the full file content as a single string)
|
||||
* into a StructuredTranscript. Pure function; no I/O.
|
||||
*
|
||||
* Malformed lines are skipped silently (matches Velocity prior art
|
||||
* `:100-102`); the lineCount counts every non-empty input line, so the
|
||||
* count of skipped lines is `lineCount - <kept-or-typed records>`.
|
||||
*/
|
||||
export function parseCodexJsonl(content: string): StructuredTranscript {
|
||||
const messages: ExtractedMessage[] = [];
|
||||
const pathCounts = new Map<string, number>();
|
||||
const typeCounts: TypeCounts = {};
|
||||
const omittedCounter = new OmittedCounter();
|
||||
let sessionMeta: SessionMeta | null = null;
|
||||
let lineCount = 0;
|
||||
let messageCount = 0;
|
||||
let compactedCount = 0;
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
if (!rawLine.trim()) continue;
|
||||
lineCount++;
|
||||
|
||||
let record: CodexRecord;
|
||||
try {
|
||||
record = JSON.parse(rawLine) as CodexRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recordType = typeof record.type === "string" ? record.type : "";
|
||||
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
|
||||
|
||||
if (recordType === "session_meta") {
|
||||
const payload = (record.payload ?? null) as Record<string, unknown> | null;
|
||||
sessionMeta = {
|
||||
cwd: typeof payload?.cwd === "string" ? payload.cwd : null,
|
||||
sessionId: typeof payload?.id === "string" ? payload.id : null,
|
||||
raw: payload ?? undefined,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recordType === "compacted") {
|
||||
compactedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recordType !== "response_item") continue;
|
||||
|
||||
const classification = classifyCodexRecord(record);
|
||||
if (classification.kind === "omitted") {
|
||||
omittedCounter.recordOmission(classification.reason);
|
||||
// Still extract paths from tool calls (matches Velocity behavior at
|
||||
// :119-129 where args/input are walked for paths even though the
|
||||
// record itself is omitted from messages).
|
||||
const payload = record.payload;
|
||||
if (payload?.type === "function_call") {
|
||||
const args = typeof payload.arguments === "string" ? payload.arguments : JSON.stringify(payload.arguments ?? {});
|
||||
extractPaths(args, pathCounts);
|
||||
} else if (payload?.type === "custom_tool_call") {
|
||||
const input = typeof payload.input === "string" ? payload.input : JSON.stringify(payload.input ?? {});
|
||||
extractPaths(input, pathCounts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kept message.
|
||||
const role = record.payload?.role;
|
||||
const roleStr = typeof role === "string" ? role : "unknown";
|
||||
if (roleStr !== "developer" && roleStr !== "user" && roleStr !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = textFromContent(record.payload?.content);
|
||||
if (hasSecretPattern(rawText)) {
|
||||
omittedCounter.recordRedaction();
|
||||
}
|
||||
const text = redact(rawText).trim();
|
||||
if (!text) continue;
|
||||
|
||||
messageCount++;
|
||||
extractPaths(text, pathCounts);
|
||||
messages.push({
|
||||
timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
|
||||
role: roleStr,
|
||||
text,
|
||||
preview: firstLine(text),
|
||||
});
|
||||
}
|
||||
|
||||
const paths: PathCount[] = [...pathCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 200)
|
||||
.map(([path, count]) => ({ path, count }));
|
||||
|
||||
return {
|
||||
sessionMeta,
|
||||
lineCount,
|
||||
messageCount,
|
||||
compactedCount,
|
||||
typeCounts,
|
||||
omittedCounts: omittedCounter.counts,
|
||||
messages,
|
||||
paths,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// omitted-records.ts — classifies a parsed source record into one of the
|
||||
// 4 omitted-class enums (per M1 contract § 5) or accepts it as a kept
|
||||
// user-visible message.
|
||||
//
|
||||
// v0 generator excludes the following record classes from
|
||||
// transcript-latest.md and transcript.md:
|
||||
//
|
||||
// - reasoning_records: internal reasoning records that the runtime emitted
|
||||
// but were not user-visible messages.
|
||||
// - raw_tool_outputs: full raw tool execution output (often verbose; may
|
||||
// contain transient state).
|
||||
// - function_call_output: function-call result records that are not
|
||||
// user-visible.
|
||||
// - redacted_secrets: applied to any matched credential patterns per the
|
||||
// redaction policy. (This class counts records whose content matched
|
||||
// a secret pattern AND was therefore redacted.)
|
||||
//
|
||||
// `omitted_classes` in the summary enumerates the classes ACTUALLY
|
||||
// excluded for THIS packet (not all available classes); the validator
|
||||
// checks each name is one of the 4 enum values.
|
||||
|
||||
import { emptyOmittedCounts, type OmittedCounts, type OmittedRecordClass } from "./types.js";
|
||||
import { hasSecretPattern } from "./redaction.js";
|
||||
|
||||
/** Source-runtime-agnostic record classifier output. */
|
||||
export type ClassifyResult =
|
||||
| { kind: "kept" }
|
||||
| { kind: "omitted"; reason: OmittedRecordClass };
|
||||
|
||||
/**
|
||||
* Classify a Codex JSONL record (the parsed JSON object) into kept /
|
||||
* omitted. Caller is responsible for passing only `response_item`-class
|
||||
* records; session_meta and compacted are handled separately by the
|
||||
* Codex parser.
|
||||
*
|
||||
* Codex semantics (per Velocity prior art):
|
||||
* - response_item with payload.type === "function_call" → function_call_output
|
||||
* - response_item with payload.type === "custom_tool_call" → raw_tool_outputs
|
||||
* - response_item with payload.type === "reasoning" → reasoning_records
|
||||
* - response_item with payload.type === "message" + role in {developer,user,assistant}
|
||||
* → kept (after secret-pattern check)
|
||||
* - everything else → reasoning_records (catch-all for non-message non-tool
|
||||
* payload types)
|
||||
*/
|
||||
export function classifyCodexRecord(record: { payload?: { type?: unknown; role?: unknown } }): ClassifyResult {
|
||||
const payloadType = typeof record.payload?.type === "string" ? record.payload.type : "";
|
||||
switch (payloadType) {
|
||||
case "function_call":
|
||||
return { kind: "omitted", reason: "function_call_output" };
|
||||
case "custom_tool_call":
|
||||
return { kind: "omitted", reason: "raw_tool_outputs" };
|
||||
case "reasoning":
|
||||
return { kind: "omitted", reason: "reasoning_records" };
|
||||
case "message": {
|
||||
const role = typeof record.payload?.role === "string" ? record.payload.role : "";
|
||||
if (role !== "developer" && role !== "user" && role !== "assistant") {
|
||||
return { kind: "omitted", reason: "reasoning_records" };
|
||||
}
|
||||
return { kind: "kept" };
|
||||
}
|
||||
default:
|
||||
return { kind: "omitted", reason: "reasoning_records" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Claude transcript record. Claude shape carries the role
|
||||
* directly at `record.type` (user / assistant / system / attachment /
|
||||
* summary etc). Per the M2b dispatch's "emit the same structured
|
||||
* representation as the Codex parser" requirement, the omitted-class
|
||||
* mapping mirrors Codex.
|
||||
*/
|
||||
export function classifyClaudeRecord(record: { type?: unknown }): ClassifyResult {
|
||||
const type = typeof record.type === "string" ? record.type : "";
|
||||
switch (type) {
|
||||
case "user":
|
||||
case "assistant":
|
||||
return { kind: "kept" };
|
||||
case "attachment":
|
||||
// Attachments are tool-result-class records (file content the user
|
||||
// attached or tool output). Map to raw_tool_outputs.
|
||||
return { kind: "omitted", reason: "raw_tool_outputs" };
|
||||
case "summary":
|
||||
// System/summary records are non-user-visible meta; map to
|
||||
// reasoning_records.
|
||||
return { kind: "omitted", reason: "reasoning_records" };
|
||||
default:
|
||||
// Everything else (sessionId-only, agentInfo, permissionMode meta,
|
||||
// etc.) is metadata, not a user-visible message.
|
||||
return { kind: "omitted", reason: "reasoning_records" };
|
||||
}
|
||||
}
|
||||
|
||||
/** State holder for accumulating omitted-class counts during parsing. */
|
||||
export class OmittedCounter {
|
||||
readonly counts: OmittedCounts = emptyOmittedCounts();
|
||||
|
||||
recordOmission(reason: OmittedRecordClass): void {
|
||||
this.counts[reason] += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that a kept message had secret content (was redacted). Increments
|
||||
* `redacted_secrets` per occurrence. Caller checks `hasSecretPattern`
|
||||
* BEFORE redaction to decide whether to record this.
|
||||
*/
|
||||
recordRedaction(): void {
|
||||
this.counts.redacted_secrets += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of omitted classes that have at least one record.
|
||||
* Used to populate `summary.omitted_classes` per contract § 5.
|
||||
* Order: stable (the enum order from the type definition).
|
||||
*/
|
||||
activeClasses(): OmittedRecordClass[] {
|
||||
const order: OmittedRecordClass[] = [
|
||||
"reasoning_records",
|
||||
"raw_tool_outputs",
|
||||
"function_call_output",
|
||||
"redacted_secrets",
|
||||
];
|
||||
return order.filter((cls) => this.counts[cls] > 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-export for convenience. */
|
||||
export { hasSecretPattern };
|
||||
@@ -0,0 +1,316 @@
|
||||
// packet-writer.ts — assembles + atomically writes a v0 restore packet
|
||||
// directory.
|
||||
//
|
||||
// Per M1 contract § 1: emits 4 required files (restore-instructions.md,
|
||||
// transcript-latest.md, touched-files.md, restore-summary.json) plus
|
||||
// optional transcript.md (when full transcript material is available;
|
||||
// presence in lockstep with `full_transcript` summary key).
|
||||
//
|
||||
// Atomic emission via tempdir-then-rename:
|
||||
// 1. Write all files into a sibling tempdir (`<targetDir>.tmp-<rand>/`).
|
||||
// 2. Validate the tempdir's restore-summary.json against the embedded
|
||||
// JSON Schema (RESTORE_SUMMARY_SCHEMA from schema-validator.ts).
|
||||
// 3. If valid: rename tempdir → targetDir (single fs.rename; atomic on
|
||||
// same filesystem).
|
||||
// 4. If validation fails OR any write step fails: rmSync the tempdir;
|
||||
// propagate the error. The targetDir is NEVER created in this case.
|
||||
//
|
||||
// Pre-condition: targetDir MUST NOT exist (operator must supply a fresh
|
||||
// path). Reject with explicit error otherwise.
|
||||
|
||||
import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync, mkdtempSync } from "node:fs";
|
||||
import { dirname, basename, join } from "node:path";
|
||||
import {
|
||||
validateRestoreSummary,
|
||||
type ValidationResult,
|
||||
} from "./schema-validator.js";
|
||||
import type { StructuredTranscript, SourceRuntime } from "./types.js";
|
||||
|
||||
const TRANSCRIPT_BOUND = 120;
|
||||
|
||||
export interface WritePacketOptions {
|
||||
/** Absolute path of the target packet directory. MUST NOT exist. */
|
||||
targetDir: string;
|
||||
/** Parsed source structured representation. */
|
||||
structured: StructuredTranscript;
|
||||
/** Runtime kind of the source seat. */
|
||||
sourceRuntime: SourceRuntime;
|
||||
/** Target rig name (operator-supplied). */
|
||||
targetRig: string;
|
||||
/** Target runtime kind (operator-supplied). */
|
||||
targetRuntime: SourceRuntime;
|
||||
/** Target workspace root (absolute path). */
|
||||
targetWorkspaceRoot: string;
|
||||
/** Default target repo (absolute path or null). */
|
||||
defaultTargetRepo: string | null;
|
||||
/** Path/URI to role guidance for the restored seat. */
|
||||
rolePointer: string;
|
||||
/** One-paragraph human-readable summary of current work. */
|
||||
currentWorkSummary: string;
|
||||
/** Who the restored seat hands off to next; "self" if continuing. */
|
||||
nextOwner: string;
|
||||
/** Caveats list (cross-runtime quirks, redaction notes, etc.). */
|
||||
caveats: string[];
|
||||
/** Explicit statement of restored-seat authority. */
|
||||
authorityBoundaries: string;
|
||||
/** Ordered source-trust ranking (subset of contract enum). */
|
||||
sourceTrustRanking: string[];
|
||||
/** Source session id. */
|
||||
sourceSessionId: string;
|
||||
/**
|
||||
* Source rig name. Per M2c-CLI R2: passed explicitly by the CLI adapter
|
||||
* (computed from parsed session_meta `<seat>@<rig>` split or operator
|
||||
* --source-rig-override). The writer does NOT derive this from
|
||||
* sourceSessionId — that hidden derivation was the M2c-CLI R1 bug.
|
||||
*/
|
||||
sourceRig: string;
|
||||
/** Source cwd (absolute path). */
|
||||
sourceCwd: string;
|
||||
/** Generator version identifier (e.g., "[email protected]"). */
|
||||
generatorVersion: string;
|
||||
/** Whether to include the full transcript.md file. */
|
||||
includeFullTranscript: boolean;
|
||||
}
|
||||
|
||||
export interface WritePacketResult {
|
||||
targetDir: string;
|
||||
files: string[];
|
||||
summaryPath: string;
|
||||
}
|
||||
|
||||
function transcriptMarkdownFromMessages(
|
||||
title: string,
|
||||
session: string,
|
||||
messages: StructuredTranscript["messages"],
|
||||
note: string,
|
||||
): string {
|
||||
const body = messages
|
||||
.map((m, i) => {
|
||||
const ts = m.timestamp ? ` ${m.timestamp}` : "";
|
||||
return `## ${i + 1}. ${m.role}${ts}\n\n${m.text}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
return `# ${title}\n\nSource session: \`${session}\`\n\n${note}\n\n${body}`;
|
||||
}
|
||||
|
||||
function buildTranscriptLatest(
|
||||
structured: StructuredTranscript,
|
||||
sourceSessionId: string,
|
||||
): string {
|
||||
const latest = structured.messages.slice(-TRANSCRIPT_BOUND);
|
||||
return transcriptMarkdownFromMessages(
|
||||
"Bounded Latest Transcript",
|
||||
sourceSessionId,
|
||||
latest,
|
||||
`This bounded transcript contains the latest ${latest.length} extracted messages for normal restore. Use transcript.md only when deeper forensic recovery is needed.`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildTranscriptFull(
|
||||
structured: StructuredTranscript,
|
||||
sourceSessionId: string,
|
||||
): string {
|
||||
return transcriptMarkdownFromMessages(
|
||||
"Full Transcript",
|
||||
sourceSessionId,
|
||||
structured.messages,
|
||||
"This transcript contains developer/user/assistant messages extracted from the source. Reasoning records, tool calls, and tool outputs are omitted by design (see restore-summary.json `omitted_classes`).",
|
||||
);
|
||||
}
|
||||
|
||||
function buildTouchedFiles(structured: StructuredTranscript): string {
|
||||
const rows = structured.paths.length > 0
|
||||
? structured.paths.map((entry) => `- \`${entry.path}\` (${entry.count})`).join("\n")
|
||||
: "- No paths detected in extracted messages or tool arguments.";
|
||||
return `# Touched Files And Path Inventory\n\nBest-effort path inventory from messages and tool arguments. This is a triage aid, not a complete proof of all files read or written.\n\n${rows}\n`;
|
||||
}
|
||||
|
||||
function buildRestoreInstructions(opts: WritePacketOptions): string {
|
||||
return `# Restore Instructions
|
||||
|
||||
You are a restored seat in \`${opts.targetRig}\`. This packet records the cross-runtime context for resuming work from \`${opts.sourceSessionId}\`.
|
||||
|
||||
## Identity
|
||||
|
||||
1. Run \`rig whoami --json\`.
|
||||
2. Trust the current rig identity over the source-session transcript.
|
||||
3. Current workspace root: \`${opts.targetWorkspaceRoot}\`.
|
||||
4. Default target repo: \`${opts.defaultTargetRepo ?? "(none)"}\`.
|
||||
5. Role guidance pointer: \`${opts.rolePointer}\`.
|
||||
|
||||
## Restore Steps
|
||||
|
||||
1. Read this file.
|
||||
2. Read \`touched-files.md\`.
|
||||
3. Read \`transcript-latest.md\` for bounded current context.
|
||||
4. Use \`transcript.md\` only if the bounded transcript is insufficient.
|
||||
5. State: "restored from packet generated by ${opts.generatorVersion}; current rig is ${opts.targetRig}; default target repo is ${opts.defaultTargetRepo ?? "none"}."
|
||||
6. Name any caveats before resuming work.
|
||||
7. Hand off to next owner: \`${opts.nextOwner}\` (or "self" to continue).
|
||||
|
||||
## Current Work
|
||||
|
||||
${opts.currentWorkSummary}
|
||||
|
||||
## Authority Boundaries
|
||||
|
||||
${opts.authorityBoundaries}
|
||||
|
||||
## Caveats
|
||||
|
||||
${opts.caveats.length > 0 ? opts.caveats.map((c) => `- ${c}`).join("\n") : "- (none recorded)"}
|
||||
|
||||
## Packet Stats
|
||||
|
||||
- Source runtime: \`${opts.sourceRuntime}\`
|
||||
- Target runtime: \`${opts.targetRuntime}\`
|
||||
- JSONL/transcript lines processed: ${opts.structured.lineCount}
|
||||
- Extracted messages: ${opts.structured.messageCount}
|
||||
- Compaction records: ${opts.structured.compactedCount}
|
||||
- Source cwd: \`${opts.sourceCwd}\`
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSummary(opts: WritePacketOptions): Record<string, unknown> {
|
||||
const omittedClassesActive: string[] = [];
|
||||
for (const k of ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"] as const) {
|
||||
if (opts.structured.omittedCounts[k] > 0) omittedClassesActive.push(k);
|
||||
}
|
||||
const messageCount = Math.min(opts.structured.messages.length, TRANSCRIPT_BOUND);
|
||||
const summary: Record<string, unknown> = {
|
||||
source_session_id: opts.sourceSessionId,
|
||||
source_rig: opts.sourceRig,
|
||||
source_runtime: opts.sourceRuntime,
|
||||
source_cwd: opts.sourceCwd,
|
||||
target_rig: opts.targetRig,
|
||||
target_runtime: opts.targetRuntime,
|
||||
target_workspace_root: opts.targetWorkspaceRoot,
|
||||
default_target_repo: opts.defaultTargetRepo,
|
||||
role_pointer: opts.rolePointer,
|
||||
bounded_latest_transcript: {
|
||||
path: "transcript-latest.md",
|
||||
message_count: messageCount,
|
||||
bound: TRANSCRIPT_BOUND,
|
||||
},
|
||||
touched_files: {
|
||||
path: "touched-files.md",
|
||||
top_paths: opts.structured.paths.slice(0, 30),
|
||||
},
|
||||
durable_pointers: {
|
||||
queue_pointers: [],
|
||||
progress_pointers: [],
|
||||
field_note_pointers: [],
|
||||
artifact_pointers: [],
|
||||
},
|
||||
current_work_summary: opts.currentWorkSummary,
|
||||
next_owner: opts.nextOwner,
|
||||
caveats: opts.caveats,
|
||||
authority_boundaries: opts.authorityBoundaries,
|
||||
omitted_classes: omittedClassesActive,
|
||||
redaction_policy_id: "openrig-v0",
|
||||
source_trust_ranking: opts.sourceTrustRanking,
|
||||
generator_version: opts.generatorVersion,
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
if (opts.includeFullTranscript) {
|
||||
summary.full_transcript = {
|
||||
path: "transcript.md",
|
||||
line_count: opts.structured.messages.length,
|
||||
};
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a v0 restore packet directory atomically.
|
||||
*
|
||||
* Atomic guarantee: if any step fails (including schema validation of
|
||||
* restore-summary.json), the targetDir is never created and any
|
||||
* intermediate tempdir is removed. Operators see either a complete
|
||||
* valid packet at targetDir, or no packet at all.
|
||||
*/
|
||||
export async function writePacket(opts: WritePacketOptions): Promise<WritePacketResult> {
|
||||
if (existsSync(opts.targetDir)) {
|
||||
throw new Error(`restore-packet write: target directory already exists: ${opts.targetDir}`);
|
||||
}
|
||||
|
||||
// Build all file contents BEFORE creating any disk state. This way if
|
||||
// an in-memory step throws (unlikely, but defensive), no tempdir was
|
||||
// created.
|
||||
const summary = buildSummary(opts);
|
||||
const validation: ValidationResult = validateRestoreSummary(summary);
|
||||
if (!validation.valid) {
|
||||
const errSummary = validation.errors
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.field}: ${e.rule}`)
|
||||
.join("; ");
|
||||
throw new Error(
|
||||
`restore-packet write: schema validation failed before atomic rename: ${errSummary}`,
|
||||
);
|
||||
}
|
||||
|
||||
const restoreInstructions = buildRestoreInstructions(opts);
|
||||
const transcriptLatest = buildTranscriptLatest(opts.structured, opts.sourceSessionId);
|
||||
const touchedFiles = buildTouchedFiles(opts.structured);
|
||||
const transcriptFull = opts.includeFullTranscript
|
||||
? buildTranscriptFull(opts.structured, opts.sourceSessionId)
|
||||
: null;
|
||||
|
||||
// Create tempdir as a sibling of targetDir using mkdtemp for race-free
|
||||
// unique name. The dirname() must exist and be writable; we don't
|
||||
// mkdir the parent for the operator (operator authority bedrock).
|
||||
const parentDir = dirname(opts.targetDir);
|
||||
const tempPrefix = `${basename(opts.targetDir)}.tmp-restore-packet-`;
|
||||
const tempDir = mkdtempSync(join(parentDir, tempPrefix));
|
||||
|
||||
try {
|
||||
writeFileSync(join(tempDir, "restore-instructions.md"), restoreInstructions, "utf-8");
|
||||
writeFileSync(join(tempDir, "transcript-latest.md"), transcriptLatest, "utf-8");
|
||||
writeFileSync(join(tempDir, "touched-files.md"), touchedFiles, "utf-8");
|
||||
writeFileSync(join(tempDir, "restore-summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
||||
if (transcriptFull !== null) {
|
||||
writeFileSync(join(tempDir, "transcript.md"), transcriptFull, "utf-8");
|
||||
}
|
||||
|
||||
// Re-validate by parsing the on-disk summary (round-trip self-check).
|
||||
// This catches any structural divergence between buildSummary's
|
||||
// in-memory shape and the JSON.stringify() serialization.
|
||||
const onDisk = JSON.parse(
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(await import("node:fs")).readFileSync(join(tempDir, "restore-summary.json"), "utf-8"),
|
||||
);
|
||||
const recheck = validateRestoreSummary(onDisk);
|
||||
if (!recheck.valid) {
|
||||
throw new Error(
|
||||
`restore-packet write: on-disk schema recheck failed: ${recheck.errors.slice(0, 5).map((e) => `${e.field}: ${e.rule}`).join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic rename tempdir → targetDir. fs.rename is atomic when source
|
||||
// and destination are on the same filesystem; we keep them sibling
|
||||
// so this holds.
|
||||
renameSync(tempDir, opts.targetDir);
|
||||
} catch (err) {
|
||||
// Clean up tempdir on any failure; do NOT leave a partial packet.
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; original error takes precedence.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const files = [
|
||||
"restore-instructions.md",
|
||||
"transcript-latest.md",
|
||||
"touched-files.md",
|
||||
"restore-summary.json",
|
||||
];
|
||||
if (transcriptFull !== null) files.push("transcript.md");
|
||||
|
||||
return {
|
||||
targetDir: opts.targetDir,
|
||||
files,
|
||||
summaryPath: join(opts.targetDir, "restore-summary.json"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// redaction.ts — applies the v0 redaction policy.
|
||||
//
|
||||
// Per M1 contract § 4: v0 defines two named policies:
|
||||
// - `velocity-v1`: mirrors the Velocity generator's token patterns at
|
||||
// field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/
|
||||
// codex-jsonl-to-restore-packet.mjs:34-48 (sk-*, gh[pousr]_*,
|
||||
// github_pat_*, Bearer *, base64 ≥40 chars).
|
||||
// - `openrig-v0`: the productized v0 policy, identical patterns to
|
||||
// `velocity-v1` but with the canonical name. v0 generator emits
|
||||
// `redaction_policy_id: "openrig-v0"`.
|
||||
//
|
||||
// Redaction is applied to:
|
||||
// - transcript content in transcript-latest.md and transcript.md.
|
||||
// - body content in restore-instructions.md if it quotes transcript
|
||||
// material.
|
||||
// - top_paths / touched-files entries are NOT redacted (paths are not
|
||||
// credential material).
|
||||
//
|
||||
// The validator does NOT scan content for residual credential patterns
|
||||
// (per § 4 + § 8: enforcement is generator-side at write time).
|
||||
|
||||
/**
|
||||
* v0 secret-pattern list. Mirrors Velocity prior art `:34-48` exactly:
|
||||
*
|
||||
* 1. /\bsk-[A-Za-z0-9_-]{16,}\b/g — OpenAI-style secret tokens.
|
||||
* 2. /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g — GitHub personal/OAuth tokens.
|
||||
* 3. /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g — GitHub fine-grained PATs.
|
||||
* 4. /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g — generic Bearer tokens.
|
||||
* 5. /\b[A-Za-z0-9+/]{40,}={0,2}\b/g — base64-shaped strings ≥40 chars.
|
||||
*/
|
||||
export const SECRET_PATTERNS: readonly RegExp[] = [
|
||||
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
||||
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
|
||||
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
||||
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g,
|
||||
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g,
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply the openrig-v0 redaction policy to a string. Returns the input
|
||||
* with each match of the secret patterns replaced by the literal
|
||||
* string `[REDACTED]`. Pure function; no side effects.
|
||||
*
|
||||
* The redaction is non-destructive at the structural level — only the
|
||||
* matched substrings are replaced. Surrounding characters are kept.
|
||||
*/
|
||||
export function redact(text: string): string {
|
||||
let out = text;
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
out = out.replace(pattern, "[REDACTED]");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if at least one secret pattern matches the input.
|
||||
* Used by the omitted-records counter to track how many records had
|
||||
* their content redacted (for the `redacted_secrets` omitted-class
|
||||
* count in summary).
|
||||
*
|
||||
* Note: a SINGLE record may match multiple patterns; this returns true
|
||||
* after the first match for efficiency.
|
||||
*/
|
||||
export function hasSecretPattern(text: string): boolean {
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
// Non-global test: cannot reuse the global `g` regex without resetting
|
||||
// lastIndex, so build a fresh non-global RegExp from source.
|
||||
const probe = new RegExp(pattern.source);
|
||||
if (probe.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// runtime-detect.ts — auto-detect the source runtime from a JSONL/transcript
|
||||
// shape.
|
||||
//
|
||||
// Per M1 contract § 3.4: source-runtime resolution for `--source-jsonl` is
|
||||
// auto-detected from the JSONL shape (Codex JSONL has `payload.type`
|
||||
// markers; Claude transcript has different shape). If auto-detect is
|
||||
// ambiguous, fail with clear error and prompt for `--source-runtime`
|
||||
// flag.
|
||||
//
|
||||
// Detection is by content shape, NOT file extension or filename
|
||||
// (per dispatch's "Do NOT use file extensions or filenames as the
|
||||
// detection key. Detect by content shape.").
|
||||
//
|
||||
// Codex shape: each line is JSON with `type` field; the discriminating
|
||||
// markers are `type: "session_meta"`, `type: "response_item"`, or
|
||||
// `type: "compacted"`. response_item records carry a `payload` object
|
||||
// with a `type` sub-field.
|
||||
//
|
||||
// Claude shape: each line is JSON with `type` field; values include
|
||||
// `user`, `assistant`, `system`, `attachment`, `summary`, etc. NO
|
||||
// `payload` wrapper; the message content is directly under `message`
|
||||
// or `attachment`.
|
||||
|
||||
import type { SourceRuntime } from "./types.js";
|
||||
|
||||
/**
|
||||
* Detect the source runtime from a sample of JSONL lines.
|
||||
*
|
||||
* Returns:
|
||||
* - "codex" — at least one line has `type: "response_item"` OR
|
||||
* `type: "session_meta"` OR `type: "compacted"`.
|
||||
* - "claude-code" — at least one line has `type: "user"` or
|
||||
* `type: "assistant"` AND no Codex markers seen.
|
||||
* - null — input is empty / non-JSONL / ambiguous (e.g., neither
|
||||
* marker class appears).
|
||||
*
|
||||
* Detection is non-greedy: walks the input lines until a definitive
|
||||
* marker is found. Bias toward Codex markers if both shape classes
|
||||
* appear (extremely unlikely in practice; would indicate a malformed
|
||||
* mixed source).
|
||||
*/
|
||||
export function detectRuntime(content: string): SourceRuntime | null {
|
||||
if (typeof content !== "string" || content.length === 0) return null;
|
||||
|
||||
let sawCodexMarker = false;
|
||||
let sawClaudeMarker = false;
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
let record: { type?: unknown; payload?: { type?: unknown } };
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip malformed JSON lines; they don't affect detection.
|
||||
continue;
|
||||
}
|
||||
|
||||
const recordType = typeof record.type === "string" ? record.type : "";
|
||||
|
||||
// Codex discriminators: type values + payload wrapper presence.
|
||||
if (
|
||||
recordType === "response_item" ||
|
||||
recordType === "session_meta" ||
|
||||
recordType === "compacted"
|
||||
) {
|
||||
sawCodexMarker = true;
|
||||
// Early-return on the strongest Codex marker; ambiguity bias
|
||||
// toward Codex if Claude markers also appear is intentional
|
||||
// (Codex's response_item is structurally distinct from any
|
||||
// Claude record; if it's present, the content is Codex JSONL).
|
||||
return "codex";
|
||||
}
|
||||
|
||||
// Claude discriminators: top-level type as a user/assistant marker.
|
||||
if (recordType === "user" || recordType === "assistant") {
|
||||
sawClaudeMarker = true;
|
||||
// Don't early-return on Claude alone — keep looking for any
|
||||
// Codex marker (defensive against the unlikely mixed-shape case).
|
||||
}
|
||||
}
|
||||
|
||||
if (sawCodexMarker) return "codex";
|
||||
if (sawClaudeMarker) return "claude-code";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// schema-validator.ts — validates restore-summary.json against the v0 JSON
|
||||
// Schema (packages/cli/src/schemas/restore-summary.schema.json).
|
||||
//
|
||||
// Per M1 contract § 8 + IMPL § M2 line 167:
|
||||
// - generator-side: emit-time self-check against the schema before atomic
|
||||
// rename of the packet directory.
|
||||
// - operator-side: M3 `rig restore-packet validate <packet-dir>` runs this
|
||||
// validator and surfaces per-field violations.
|
||||
//
|
||||
// Validator returns a ValidationResult with `valid` and a per-field error
|
||||
// list. Each error names: field path, value (truncated/escaped), rule, and
|
||||
// severity. Required-field violations are severity `error`; optional-field
|
||||
// malformations are severity `warning` per § 8.
|
||||
//
|
||||
// M2a R2 packaging fix: the schema is embedded directly in this module as
|
||||
// a typed TS const (RESTORE_SUMMARY_SCHEMA) rather than read from the
|
||||
// sibling .json file at runtime. tsc emits the const into the compiled
|
||||
// validator JS, so the validator works after `tsc` emit without any extra
|
||||
// build-script copy step. The canonical JSON Schema file at
|
||||
// `packages/cli/src/schemas/restore-summary.schema.json` remains the
|
||||
// source of truth for downstream IDE / tooling consumption; a drift-catcher
|
||||
// test in `test/restore-packet.test.ts` asserts the two stay byte-equivalent.
|
||||
|
||||
import Ajv from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
value: string;
|
||||
rule: string;
|
||||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
// RESTORE_SUMMARY_SCHEMA — verbatim mirror of
|
||||
// `packages/cli/src/schemas/restore-summary.schema.json`. Keep these in
|
||||
// lockstep; the drift-catcher test enforces equivalence.
|
||||
export const RESTORE_SUMMARY_SCHEMA: Record<string, unknown> = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://openrig.dev/schemas/restore-summary.schema.json",
|
||||
title: "Restore Packet Summary",
|
||||
description:
|
||||
"Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
|
||||
type: "object",
|
||||
required: [
|
||||
"source_session_id",
|
||||
"source_rig",
|
||||
"source_runtime",
|
||||
"source_cwd",
|
||||
"target_rig",
|
||||
"target_runtime",
|
||||
"target_workspace_root",
|
||||
"default_target_repo",
|
||||
"role_pointer",
|
||||
"bounded_latest_transcript",
|
||||
"touched_files",
|
||||
"durable_pointers",
|
||||
"current_work_summary",
|
||||
"next_owner",
|
||||
"caveats",
|
||||
"authority_boundaries",
|
||||
"omitted_classes",
|
||||
"redaction_policy_id",
|
||||
"source_trust_ranking",
|
||||
"generator_version",
|
||||
"generated_at",
|
||||
],
|
||||
properties: {
|
||||
source_session_id: { type: "string", minLength: 1 },
|
||||
source_rig: { type: "string", minLength: 1 },
|
||||
source_runtime: {
|
||||
type: "string",
|
||||
enum: ["claude-code", "codex", "terminal", "external"],
|
||||
},
|
||||
source_cwd: { type: "string", pattern: "^/" },
|
||||
target_rig: { type: "string", minLength: 1 },
|
||||
target_runtime: {
|
||||
type: "string",
|
||||
enum: ["claude-code", "codex", "terminal", "external"],
|
||||
},
|
||||
target_workspace_root: { type: "string", pattern: "^/" },
|
||||
default_target_repo: { type: ["string", "null"] },
|
||||
role_pointer: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description:
|
||||
"Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time.",
|
||||
},
|
||||
bounded_latest_transcript: {
|
||||
type: "object",
|
||||
required: ["path", "message_count", "bound"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
message_count: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
description: "Actual count of extracted messages in transcript-latest.md.",
|
||||
},
|
||||
bound: {
|
||||
type: "integer",
|
||||
const: 120,
|
||||
description:
|
||||
"v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass.",
|
||||
},
|
||||
},
|
||||
},
|
||||
full_transcript: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_count: { type: "integer", minimum: 0 },
|
||||
},
|
||||
description:
|
||||
"Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise.",
|
||||
},
|
||||
touched_files: {
|
||||
type: "object",
|
||||
required: ["path", "top_paths"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
top_paths: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["path", "count"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
count: { type: "integer", minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
durable_pointers: {
|
||||
type: "object",
|
||||
required: [
|
||||
"queue_pointers",
|
||||
"progress_pointers",
|
||||
"field_note_pointers",
|
||||
"artifact_pointers",
|
||||
],
|
||||
properties: {
|
||||
queue_pointers: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "References to durable queue items (qitem ids or queue-file paths).",
|
||||
},
|
||||
progress_pointers: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "References to PROGRESS.md cursors the source seat was operating against.",
|
||||
},
|
||||
field_note_pointers: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "References to field-notes folders or files.",
|
||||
},
|
||||
artifact_pointers: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Other durable artifacts (proof packets, slice packets, candidate dossiers).",
|
||||
},
|
||||
},
|
||||
description:
|
||||
"REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context.",
|
||||
},
|
||||
current_work_summary: { type: "string", minLength: 1 },
|
||||
next_owner: { type: "string", minLength: 1 },
|
||||
caveats: { type: "array", items: { type: "string" } },
|
||||
authority_boundaries: { type: "string", minLength: 1 },
|
||||
omitted_classes: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"],
|
||||
},
|
||||
},
|
||||
redaction_policy_id: { type: "string", enum: ["velocity-v1", "openrig-v0"] },
|
||||
source_trust_ranking: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"rig_whoami",
|
||||
"target_rigspec",
|
||||
"bounded_latest_transcript",
|
||||
"full_transcript",
|
||||
"touched_files",
|
||||
"restore_summary",
|
||||
],
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
generator_version: { type: "string", minLength: 1 },
|
||||
generated_at: { type: "string", format: "date-time" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
let cachedValidator: ReturnType<Ajv["compile"]> | null = null;
|
||||
|
||||
function getValidator(): ReturnType<Ajv["compile"]> {
|
||||
if (cachedValidator) return cachedValidator;
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
cachedValidator = ajv.compile(RESTORE_SUMMARY_SCHEMA);
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
function truncateValue(value: unknown): string {
|
||||
if (value === undefined) return "<undefined>";
|
||||
if (value === null) return "null";
|
||||
let s: string;
|
||||
try {
|
||||
s = typeof value === "string" ? value : JSON.stringify(value);
|
||||
} catch {
|
||||
s = String(value);
|
||||
}
|
||||
if (s.length > 80) return s.slice(0, 77) + "...";
|
||||
return s;
|
||||
}
|
||||
|
||||
export function validateRestoreSummary(summary: unknown): ValidationResult {
|
||||
const validator = getValidator();
|
||||
const ok = validator(summary);
|
||||
if (ok) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
const errors: ValidationError[] = (validator.errors ?? []).map((err) => {
|
||||
const instancePath = err.instancePath || "";
|
||||
const missing = err.params && (err.params as { missingProperty?: string }).missingProperty;
|
||||
const additional = err.params && (err.params as { additionalProperty?: string }).additionalProperty;
|
||||
let field = instancePath.replace(/^\//, "").replace(/\//g, ".");
|
||||
if (missing) field = field ? `${field}.${missing}` : missing;
|
||||
if (additional) field = additional;
|
||||
// Per M1 contract § 8 + IMPL § M3: optional-field malformations are
|
||||
// severity "warning"; required-field violations are severity "error".
|
||||
// The only top-level optional field in v0 is `full_transcript`.
|
||||
// Errors whose path begins at `/full_transcript` come from validating
|
||||
// the optional object's contents, not from a missing-required check
|
||||
// on a required field.
|
||||
const isOptionalFieldError = instancePath.startsWith("/full_transcript");
|
||||
return {
|
||||
field: field || "<root>",
|
||||
value: truncateValue(err.data),
|
||||
rule: `${err.keyword}: ${err.message ?? ""}`.trim(),
|
||||
severity: isOptionalFieldError ? "warning" : "error",
|
||||
};
|
||||
});
|
||||
// `valid` is false on ANY ajv violation (preserves M2c writer
|
||||
// semantics: any malformation should block atomic rename). The M3
|
||||
// validate command consults `errors[].severity` to decide exit code
|
||||
// per M1 contract § 8 (required-field-class errors → exit nonzero;
|
||||
// optional-field warnings → exit 0 with warning text).
|
||||
return { valid: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// types.ts — shared TypeScript types for the restore-packet generator.
|
||||
//
|
||||
// Both runtime adapters (codex-jsonl-parser, claude-transcript-parser)
|
||||
// emit the same StructuredTranscript shape. Downstream modules
|
||||
// (redaction, omitted-records, packet-writer) consume this shape
|
||||
// without caring about the source runtime.
|
||||
|
||||
/** A single user-visible message extracted from a transcript. */
|
||||
export interface ExtractedMessage {
|
||||
/** ISO-8601 timestamp if the source provides one; else null. */
|
||||
timestamp: string | null;
|
||||
/** Speaker role. Restricted to user-visible roles (developer/user/assistant). */
|
||||
role: "developer" | "user" | "assistant";
|
||||
/** Redacted message text. */
|
||||
text: string;
|
||||
/** First non-empty line of the text, truncated to ~180 chars. Used in summaries. */
|
||||
preview: string;
|
||||
}
|
||||
|
||||
/** Canonical record-class enum per M1 contract § 5. */
|
||||
export type OmittedRecordClass =
|
||||
| "reasoning_records"
|
||||
| "raw_tool_outputs"
|
||||
| "function_call_output"
|
||||
| "redacted_secrets";
|
||||
|
||||
/**
|
||||
* A path frequency entry from path extraction. Sorted by count
|
||||
* descending then path ascending; used to populate `touched_files.top_paths`.
|
||||
*/
|
||||
export interface PathCount {
|
||||
path: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-runtime per-record-type counter. Flexible string keys because
|
||||
* Codex JSONL uses `record.type` strings and Claude transcript uses
|
||||
* `record.type` strings; both are runtime-defined.
|
||||
*/
|
||||
export type TypeCounts = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Per-omitted-class counter. Records how many records were filtered
|
||||
* for each of the 4 contract enums during parsing/redaction.
|
||||
*/
|
||||
export type OmittedCounts = Record<OmittedRecordClass, number>;
|
||||
|
||||
/**
|
||||
* Optional session metadata extracted from the source. Codex emits a
|
||||
* `session_meta` record at the top of a JSONL; Claude transcripts
|
||||
* carry per-record `cwd` etc.
|
||||
*/
|
||||
export interface SessionMeta {
|
||||
cwd: string | null;
|
||||
sessionId: string | null;
|
||||
/** Free-form additional fields the runtime emitted; not load-bearing. */
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured representation produced by both parsers. The packet-writer
|
||||
* (M2c) consumes this to assemble a v0 restore packet.
|
||||
*/
|
||||
export interface StructuredTranscript {
|
||||
/** Top-level session metadata (cwd, sessionId). */
|
||||
sessionMeta: SessionMeta | null;
|
||||
/** Total source lines processed (raw JSONL line count, including malformed/skipped). */
|
||||
lineCount: number;
|
||||
/** Number of user-visible messages extracted into `messages`. */
|
||||
messageCount: number;
|
||||
/**
|
||||
* Number of "compaction" records seen. Codex emits these explicitly
|
||||
* (`type: "compacted"`); Claude doesn't. The field is parser-specific
|
||||
* but always present (zero when not applicable).
|
||||
*/
|
||||
compactedCount: number;
|
||||
/** Per-record-type frequency map; used in human reports / debug. */
|
||||
typeCounts: TypeCounts;
|
||||
/** Per-omitted-class counter; populated as the parser filters records. */
|
||||
omittedCounts: OmittedCounts;
|
||||
/** Extracted messages in chronological order; redaction has been applied. */
|
||||
messages: ExtractedMessage[];
|
||||
/**
|
||||
* Path frequency inventory; sorted by count desc then path asc.
|
||||
* Capped at 200 entries.
|
||||
*/
|
||||
paths: PathCount[];
|
||||
}
|
||||
|
||||
/** Source-runtime kind. v0 supports codex + claude-code transcript shapes. */
|
||||
export type SourceRuntime = "codex" | "claude-code";
|
||||
|
||||
export function emptyOmittedCounts(): OmittedCounts {
|
||||
return {
|
||||
reasoning_records: 0,
|
||||
raw_tool_outputs: 0,
|
||||
function_call_output: 0,
|
||||
redacted_secrets: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://openrig.dev/schemas/restore-summary.schema.json",
|
||||
"title": "Restore Packet Summary",
|
||||
"description": "Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source_session_id",
|
||||
"source_rig",
|
||||
"source_runtime",
|
||||
"source_cwd",
|
||||
"target_rig",
|
||||
"target_runtime",
|
||||
"target_workspace_root",
|
||||
"default_target_repo",
|
||||
"role_pointer",
|
||||
"bounded_latest_transcript",
|
||||
"touched_files",
|
||||
"durable_pointers",
|
||||
"current_work_summary",
|
||||
"next_owner",
|
||||
"caveats",
|
||||
"authority_boundaries",
|
||||
"omitted_classes",
|
||||
"redaction_policy_id",
|
||||
"source_trust_ranking",
|
||||
"generator_version",
|
||||
"generated_at"
|
||||
],
|
||||
"properties": {
|
||||
"source_session_id": {"type": "string", "minLength": 1},
|
||||
"source_rig": {"type": "string", "minLength": 1},
|
||||
"source_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
|
||||
"source_cwd": {"type": "string", "pattern": "^/"},
|
||||
"target_rig": {"type": "string", "minLength": 1},
|
||||
"target_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
|
||||
"target_workspace_root": {"type": "string", "pattern": "^/"},
|
||||
"default_target_repo": {"type": ["string", "null"]},
|
||||
"role_pointer": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time."
|
||||
},
|
||||
"bounded_latest_transcript": {
|
||||
"type": "object",
|
||||
"required": ["path", "message_count", "bound"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"message_count": {"type": "integer", "minimum": 0, "description": "Actual count of extracted messages in transcript-latest.md."},
|
||||
"bound": {"type": "integer", "const": 120, "description": "v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass."}
|
||||
}
|
||||
},
|
||||
"full_transcript": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"line_count": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"description": "Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise."
|
||||
},
|
||||
"touched_files": {
|
||||
"type": "object",
|
||||
"required": ["path", "top_paths"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"top_paths": {"type": "array", "items": {"type": "object", "required": ["path", "count"], "properties": {"path": {"type": "string"}, "count": {"type": "integer", "minimum": 0}}}}
|
||||
}
|
||||
},
|
||||
"durable_pointers": {
|
||||
"type": "object",
|
||||
"required": ["queue_pointers", "progress_pointers", "field_note_pointers", "artifact_pointers"],
|
||||
"properties": {
|
||||
"queue_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to durable queue items (qitem ids or queue-file paths)."},
|
||||
"progress_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to PROGRESS.md cursors the source seat was operating against."},
|
||||
"field_note_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to field-notes folders or files."},
|
||||
"artifact_pointers": {"type": "array", "items": {"type": "string"}, "description": "Other durable artifacts (proof packets, slice packets, candidate dossiers)."}
|
||||
},
|
||||
"description": "REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context."
|
||||
},
|
||||
"current_work_summary": {"type": "string", "minLength": 1},
|
||||
"next_owner": {"type": "string", "minLength": 1},
|
||||
"caveats": {"type": "array", "items": {"type": "string"}},
|
||||
"authority_boundaries": {"type": "string", "minLength": 1},
|
||||
"omitted_classes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"]}
|
||||
},
|
||||
"redaction_policy_id": {"type": "string", "enum": ["velocity-v1", "openrig-v0"]},
|
||||
"source_trust_ranking": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["rig_whoami", "target_rigspec", "bounded_latest_transcript", "full_transcript", "touched_files", "restore_summary"]},
|
||||
"minItems": 1
|
||||
},
|
||||
"generator_version": {"type": "string", "minLength": 1},
|
||||
"generated_at": {"type": "string", "format": "date-time"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { accessSync, mkdirSync, constants } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { ConfigStore, RiggedConfig } from "./config-store.js";
|
||||
import type { DaemonStatus } from "./daemon-lifecycle.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControlAsync } from "./tmux-health.js";
|
||||
|
||||
export interface PreflightCheck {
|
||||
name: string;
|
||||
@@ -135,10 +136,8 @@ export class SystemPreflight {
|
||||
}
|
||||
|
||||
// 2. tmux availability
|
||||
try {
|
||||
await this.deps.exec("tmux -V");
|
||||
checks.push({ name: "tmux", ok: true });
|
||||
} catch {
|
||||
const tmuxProbe = await probeTmuxControlAsync(this.deps.exec);
|
||||
if (tmuxProbe.code === "not_installed") {
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
ok: false,
|
||||
@@ -146,6 +145,17 @@ export class SystemPreflight {
|
||||
reason: "OpenRig uses tmux to create and control agent sessions.",
|
||||
fix: "Install tmux (brew install tmux on macOS, apt install tmux on Debian/Ubuntu).",
|
||||
});
|
||||
} else if (tmuxProbe.available) {
|
||||
checks.push({ name: "tmux", ok: true });
|
||||
} else {
|
||||
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
ok: false,
|
||||
error: failure.message,
|
||||
reason: failure.reason,
|
||||
fix: failure.fix,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Writable OpenRig home + transcript path
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export interface TmuxProbeResult {
|
||||
installed: boolean;
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
detail: string | null;
|
||||
code: "available" | "no_server" | "not_installed" | "unhealthy";
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export function isTmuxNoServerMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes("no server running")
|
||||
|| normalized.includes("failed to connect to server")
|
||||
|| (normalized.includes("error connecting to") && normalized.includes("no such file or directory"));
|
||||
}
|
||||
|
||||
export function buildTmuxControlFailure(message: string): { message: string; reason: string; fix: string } {
|
||||
return {
|
||||
message: "tmux installed, but the default control socket is unhealthy.",
|
||||
reason: `OpenRig uses tmux control commands to launch, inspect, and manage agent sessions. The default tmux socket is returning: ${message}`,
|
||||
fix: "Capture any needed state from visible tmux panes, then restart the default tmux server before retrying OpenRig. If this happened after a machine restore, treat it as attention required rather than a healthy running state.",
|
||||
};
|
||||
}
|
||||
|
||||
export function probeTmuxControl(exec: (cmd: string) => string): TmuxProbeResult {
|
||||
let version: string;
|
||||
try {
|
||||
version = exec("tmux -V").trim();
|
||||
} catch (err) {
|
||||
return {
|
||||
installed: false,
|
||||
available: false,
|
||||
version: null,
|
||||
detail: errorMessage(err),
|
||||
code: "not_installed",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
exec("tmux list-sessions");
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail: null,
|
||||
code: "available",
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = errorMessage(err).trim();
|
||||
if (isTmuxNoServerMessage(detail)) {
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail,
|
||||
code: "no_server",
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
available: false,
|
||||
version,
|
||||
detail,
|
||||
code: "unhealthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeTmuxControlAsync(exec: (cmd: string) => Promise<string>): Promise<TmuxProbeResult> {
|
||||
let version: string;
|
||||
try {
|
||||
version = (await exec("tmux -V")).trim();
|
||||
} catch (err) {
|
||||
return {
|
||||
installed: false,
|
||||
available: false,
|
||||
version: null,
|
||||
detail: errorMessage(err),
|
||||
code: "not_installed",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await exec("tmux list-sessions");
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail: null,
|
||||
code: "available",
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = errorMessage(err).trim();
|
||||
if (isTmuxNoServerMessage(detail)) {
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail,
|
||||
code: "no_server",
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
available: false,
|
||||
version,
|
||||
detail,
|
||||
code: "unhealthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { resolveBinEntry } from "../src/bin-wrapper.js";
|
||||
import { resolveBinEntry, resolveNodeReexecBinary } from "../src/bin-wrapper.js";
|
||||
|
||||
describe("bin-wrapper", () => {
|
||||
let tempDir: string | null = null;
|
||||
@@ -24,4 +24,50 @@ describe("bin-wrapper", () => {
|
||||
const entry = resolveBinEntry(symlinkPath, pathToFileURL(wrapperSource).href);
|
||||
expect(entry).toBe(path.resolve(path.dirname(wrapperSource), "../dist/index.js"));
|
||||
});
|
||||
|
||||
it("prefers the sibling node next to the invoked rig binary when current node comes from elsewhere", () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
|
||||
const prefixDir = path.join(tempDir, "prefix");
|
||||
const binDir = path.join(prefixDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
|
||||
const rigPath = path.join(binDir, "rig");
|
||||
const siblingNodePath = path.join(binDir, "node");
|
||||
fs.symlinkSync(wrapperSource, rigPath);
|
||||
fs.writeFileSync(siblingNodePath, "");
|
||||
|
||||
const reexecNode = resolveNodeReexecBinary(
|
||||
"/opt/homebrew/bin/node",
|
||||
rigPath,
|
||||
{},
|
||||
(candidate) => candidate === siblingNodePath,
|
||||
(candidate) => candidate,
|
||||
);
|
||||
|
||||
expect(reexecNode).toBe(siblingNodePath);
|
||||
});
|
||||
|
||||
it("does not re-exec when already running under the sibling node", () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
|
||||
const prefixDir = path.join(tempDir, "prefix");
|
||||
const binDir = path.join(prefixDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
|
||||
const rigPath = path.join(binDir, "rig");
|
||||
const siblingNodePath = path.join(binDir, "node");
|
||||
fs.symlinkSync(wrapperSource, rigPath);
|
||||
fs.writeFileSync(siblingNodePath, "");
|
||||
|
||||
const reexecNode = resolveNodeReexecBinary(
|
||||
siblingNodePath,
|
||||
rigPath,
|
||||
{},
|
||||
(candidate) => candidate === siblingNodePath,
|
||||
(candidate) => candidate,
|
||||
);
|
||||
|
||||
expect(reexecNode).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -182,7 +182,9 @@ describe("Bootstrap CLI", () => {
|
||||
// T7: daemon not running -> error + exit 1
|
||||
it("daemon not running returns exit code 1", async () => {
|
||||
const stoppedDeps: StatusDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps(),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
const prog = new Command();
|
||||
@@ -214,6 +216,32 @@ describe("Bootstrap CLI", () => {
|
||||
expect((body.cwdOverride as string).endsWith("relative/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("bootstrap apply uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||||
timeoutMs = options?.timeoutMs;
|
||||
return {
|
||||
status: 201,
|
||||
data: { runId: "run-2", status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
|
||||
};
|
||||
});
|
||||
|
||||
const deps: StatusDeps = {
|
||||
lifecycleDeps: runningDeps(port).lifecycleDeps,
|
||||
clientFactory: () => ({ post } as unknown as DaemonClient),
|
||||
};
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(bootstrapCommand(deps));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "bootstrap", "/tmp/rig.yaml", "--yes"]);
|
||||
});
|
||||
|
||||
expect(timeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
// T9: partial status -> exit 1 (R2-M1)
|
||||
it("partial result returns exit code 1", async () => {
|
||||
const partialServer = http.createServer((_, res) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { chatroomCommand } from "../src/commands/chatroom.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
@@ -308,7 +309,7 @@ describe("Chatroom CLI", () => {
|
||||
// Inject a new message after 1s (will appear on second poll)
|
||||
const injectionTimer = setTimeout(() => {
|
||||
dynamicMessages.push({
|
||||
id: "01KNZZZZZZZZZZZZZZZZZZZZZZ", // ULID > any current ULID baseline
|
||||
id: ulid(),
|
||||
rigId: "rig-1",
|
||||
sender: "new-peer",
|
||||
kind: "message",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkAbi } from "../scripts/check-abi.mjs";
|
||||
|
||||
describe("postinstall ABI check", () => {
|
||||
it("passes on supported even-numbered LTS with working native addon", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v22.22.1",
|
||||
loadNativeAddon: () => {},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("passes on Node 20 LTS", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v20.18.0",
|
||||
loadNativeAddon: () => {},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("passes on Node 24 LTS", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v24.0.0",
|
||||
loadNativeAddon: () => {},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails on odd-numbered Node 25 with honest error + fix command", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v25.8.0",
|
||||
loadNativeAddon: () => {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain("odd-numbered");
|
||||
expect(result.message).toContain("v25.8.0");
|
||||
expect(result.message).toContain("nvm install 22");
|
||||
});
|
||||
|
||||
it("fails on odd-numbered Node 23 with honest error", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v23.5.0",
|
||||
loadNativeAddon: () => {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain("odd-numbered");
|
||||
});
|
||||
|
||||
it("fails on Node below 20 with version-too-low error", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v18.20.0",
|
||||
loadNativeAddon: () => {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain("requires Node.js 20, 22, or 24");
|
||||
expect(result.message).toContain("nvm install 22");
|
||||
});
|
||||
|
||||
it("fails with ABI mismatch error when native addon throws on supported version", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v22.22.1",
|
||||
loadNativeAddon: () => {
|
||||
throw new Error(
|
||||
"The module was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " +
|
||||
"This version of Node.js requires NODE_MODULE_VERSION 131."
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain("native binary does not match");
|
||||
expect(result.message).toContain("npm rebuild better-sqlite3");
|
||||
expect(result.message).toContain("NODE_MODULE_VERSION");
|
||||
});
|
||||
|
||||
it("reports generic native-load-failure for MODULE_NOT_FOUND (not ABI-mismatch text)", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v22.22.1",
|
||||
loadNativeAddon: () => {
|
||||
const err = new Error("Cannot find module 'better-sqlite3'");
|
||||
(err as NodeJS.ErrnoException).code = "MODULE_NOT_FOUND";
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
// Should NOT say "native binary does not match" — that's ABI-specific
|
||||
expect(result.message).not.toContain("native binary does not match");
|
||||
// Should say generic addon-load-failure
|
||||
expect(result.message).toContain("native addon failed to load");
|
||||
expect(result.message).toContain("Cannot find module");
|
||||
expect(result.message).toContain("npm rebuild better-sqlite3");
|
||||
});
|
||||
|
||||
it("reports generic native-load-failure for filesystem permission errors", () => {
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v22.22.1",
|
||||
loadNativeAddon: () => {
|
||||
throw new Error("EACCES: permission denied, open '/usr/local/lib/better_sqlite3.node'");
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).not.toContain("native binary does not match");
|
||||
expect(result.message).toContain("native addon failed to load");
|
||||
expect(result.message).toContain("EACCES");
|
||||
});
|
||||
|
||||
it("skips native addon check entirely for odd versions (fast path)", () => {
|
||||
let addonCalled = false;
|
||||
const result = checkAbi({
|
||||
nodeVersion: "v25.8.0",
|
||||
loadNativeAddon: () => { addonCalled = true; },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
// Version check short-circuits before trying to load the addon
|
||||
expect(addonCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,29 @@ describe("DaemonClient", () => {
|
||||
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
|
||||
it("per-request timeout override can extend a long-running call", async () => {
|
||||
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
}, 40);
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(init.signal?.reason ?? new Error("aborted"));
|
||||
}, { once: true });
|
||||
})) as typeof fetch;
|
||||
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
|
||||
|
||||
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
|
||||
|
||||
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.ok).toBe(true);
|
||||
expect(res.data.url).toContain("/api/rigs");
|
||||
});
|
||||
|
||||
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
|
||||
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
|
||||
// Default (no env)
|
||||
@@ -152,8 +175,8 @@ describe("DaemonClient", () => {
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { resolve } = await import("node:path");
|
||||
|
||||
const cliEntry = resolve(import.meta.dirname, "../src/index.ts");
|
||||
const result = await execFileAsync("npx", ["tsx", cliEntry, "--version"], {
|
||||
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
|
||||
const result = await execFileAsync("node", [cliEntry, "--version"], {
|
||||
cwd: resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "../src/commands/compact-plan.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
const freshSample = () => new Date(Date.now() - 60_000).toISOString();
|
||||
const staleSample = () => new Date(Date.now() - 3_600_000).toISOString();
|
||||
|
||||
interface TestNode {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
logicalId: string;
|
||||
canonicalSessionName: string | null;
|
||||
runtime: string | null;
|
||||
sessionStatus: string | null;
|
||||
startupStatus: string | null;
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
contextUsage?: {
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
source: string | null;
|
||||
availability: string | null;
|
||||
sampledAt: string | null;
|
||||
fresh: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function node(overrides: Partial<TestNode> = {}): TestNode {
|
||||
const session = overrides.canonicalSessionName ?? "orch-lead@test-rig";
|
||||
return {
|
||||
rigId: "rig-0",
|
||||
rigName: "test-rig",
|
||||
logicalId: "orch.lead",
|
||||
canonicalSessionName: session,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready",
|
||||
tmuxAttachCommand: session ? `tmux attach -t ${session}` : null,
|
||||
resumeCommand: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 85,
|
||||
remainingPercentage: 15,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultNodes(): TestNode[] {
|
||||
return [
|
||||
node({
|
||||
logicalId: "orch.lead",
|
||||
canonicalSessionName: "orch-lead@test-rig",
|
||||
contextUsage: {
|
||||
usedPercentage: 85,
|
||||
remainingPercentage: 15,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
node({
|
||||
logicalId: "dev.impl",
|
||||
canonicalSessionName: "dev-impl@test-rig",
|
||||
contextUsage: {
|
||||
usedPercentage: 30,
|
||||
remainingPercentage: 70,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
node({
|
||||
logicalId: "dev.qa",
|
||||
canonicalSessionName: "dev-qa@test-rig",
|
||||
runtime: "codex",
|
||||
contextUsage: {
|
||||
usedPercentage: 90,
|
||||
remainingPercentage: 10,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "codex",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
rigNames?: string[];
|
||||
nodesByRig?: Record<string, TestNode[]>;
|
||||
refreshStatus?: number;
|
||||
refreshThrows?: boolean;
|
||||
}): { deps: CompactPlanDeps; requestedPaths: string[]; mutationMethods: Record<string, ReturnType<typeof vi.fn>> } {
|
||||
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({ rigId: `rig-${i}`, name }));
|
||||
const nodesByRig = overrides?.nodesByRig ?? { "rig-0": defaultNodes() };
|
||||
const requestedPaths: string[] = [];
|
||||
const mutationMethods = {
|
||||
post: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
|
||||
delete: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
|
||||
postText: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
|
||||
postExpectText: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
|
||||
};
|
||||
|
||||
return {
|
||||
requestedPaths,
|
||||
mutationMethods,
|
||||
deps: {
|
||||
lifecycleDeps: {} as CompactPlanDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
requestedPaths.push(path);
|
||||
if (path === "/api/ps") return { status: 200, data: rigs };
|
||||
if (path.includes("refresh=true")) {
|
||||
if (overrides?.refreshThrows) throw new Error("statusline unavailable");
|
||||
return { status: overrides?.refreshStatus ?? 200, data: overrides?.refreshStatus && overrides.refreshStatus >= 400 ? { error: "refresh failed" } : [] };
|
||||
}
|
||||
for (const [rigId, nodes] of Object.entries(nodesByRig)) {
|
||||
if (path === `/api/rigs/${rigId}/nodes`) return { status: 200, data: nodes };
|
||||
}
|
||||
return { status: 200, data: [] };
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => {
|
||||
requestedPaths.push(path);
|
||||
return { status: 200, data: "" };
|
||||
}),
|
||||
...mutationMethods,
|
||||
}) as unknown as ReturnType<CompactPlanDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("rig compact-plan", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
|
||||
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
|
||||
(getDaemonStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ state: "running", healthy: true, pid: 1234, port: 7433 });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("is registered on createProgram and runs with injected deps", async () => {
|
||||
const { deps } = makeDeps();
|
||||
const program = createProgram({ compactPlanDeps: deps });
|
||||
program.exitOverride();
|
||||
|
||||
await program.parseAsync(["node", "rig", "compact-plan", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.policy.mode).toBe("read_only_plan");
|
||||
expect(json.candidates[0].session).toBe("orch-lead@test-rig");
|
||||
});
|
||||
|
||||
it("returns read-only JSON policy, candidates, recommended order, and skipped Codex seats", async () => {
|
||||
const { deps, mutationMethods } = makeDeps();
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.policy).toEqual({
|
||||
mode: "read_only_plan",
|
||||
defaultThresholdTokens: 400_000,
|
||||
defaultThresholdPercent: 80,
|
||||
thresholdTokens: 400_000,
|
||||
thresholdPercent: 80,
|
||||
oneSeatAtATime: true,
|
||||
autoCompactAllowed: false,
|
||||
explicitAuthorizationRequired: true,
|
||||
});
|
||||
expect(json.summary.requiresAuthorization).toBe(true);
|
||||
|
||||
const candidate = json.candidates.find((entry: { session: string }) => entry.session === "orch-lead@test-rig");
|
||||
expect(candidate.status).toBe("candidate_with_caveats");
|
||||
expect(candidate.estimatedUsedTokens).toBe(850_000);
|
||||
expect(candidate.thresholdReason).toBe("above_token_threshold");
|
||||
expect(candidate.reasons).toContain("above_token_threshold");
|
||||
expect(candidate.reasons).toContain("authorization_required");
|
||||
expect(candidate.missingEvidence).toContain("checkpoint_or_restore_packet_verification");
|
||||
expect(candidate.precompactRequirements).toContain("explicit_operator_authorization");
|
||||
expect(candidate.seatPolicy).toEqual({
|
||||
oneSeatAtATime: true,
|
||||
autoCompactAllowed: false,
|
||||
explicitAuthorizationRequired: true,
|
||||
});
|
||||
expect(candidate.notificationPacket.recipient).toBe("orch-lead@test-rig");
|
||||
expect(candidate.notificationPacket.text).toContain("No automatic compaction has been run");
|
||||
expect(json.recommendedOrder).toEqual(["orch-lead@test-rig"]);
|
||||
|
||||
const belowThreshold = json.skipped.find((entry: { session: string }) => entry.session === "dev-impl@test-rig");
|
||||
expect(belowThreshold.reason).toBe("below_threshold");
|
||||
|
||||
const codex = json.skipped.find((entry: { session: string }) => entry.session === "dev-qa@test-rig");
|
||||
expect(codex.reason).toBe("codex_not_managed_by_claude_compact_in_place");
|
||||
for (const mutate of Object.values(mutationMethods)) {
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses percent fallback only for Claude seats at or above 80 percent when context window is missing", async () => {
|
||||
const { deps } = makeDeps({
|
||||
nodesByRig: {
|
||||
"rig-0": [
|
||||
node({
|
||||
canonicalSessionName: "high-missing-window@test-rig",
|
||||
contextUsage: {
|
||||
usedPercentage: 85,
|
||||
remainingPercentage: 15,
|
||||
contextWindowSize: null,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
node({
|
||||
logicalId: "mid.missing",
|
||||
canonicalSessionName: "mid-missing-window@test-rig",
|
||||
contextUsage: {
|
||||
usedPercentage: 79,
|
||||
remainingPercentage: 21,
|
||||
contextWindowSize: null,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.candidates.map((entry: { session: string }) => entry.session)).toContain("high-missing-window@test-rig");
|
||||
const candidate = json.candidates.find((entry: { session: string }) => entry.session === "high-missing-window@test-rig");
|
||||
expect(candidate.status).toBe("candidate_with_caveats");
|
||||
expect(candidate.thresholdReason).toBe("above_percent_threshold_missing_window");
|
||||
expect(candidate.estimatedUsedTokens).toBeNull();
|
||||
expect(candidate.missingEvidence).toContain("context_window_size");
|
||||
expect(json.skipped.find((entry: { session: string }) => entry.session === "mid-missing-window@test-rig").reason).toBe("below_threshold");
|
||||
});
|
||||
|
||||
it("honors custom token and percent thresholds in JSON policy and candidate selection", async () => {
|
||||
const { deps } = makeDeps({
|
||||
nodesByRig: {
|
||||
"rig-0": [
|
||||
node({
|
||||
logicalId: "lower.token",
|
||||
canonicalSessionName: "lower-token@test-rig",
|
||||
contextUsage: {
|
||||
usedPercentage: 30,
|
||||
remainingPercentage: 70,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
node({
|
||||
logicalId: "lower.percent",
|
||||
canonicalSessionName: "lower-percent@test-rig",
|
||||
contextUsage: {
|
||||
usedPercentage: 75,
|
||||
remainingPercentage: 25,
|
||||
contextWindowSize: null,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--threshold-tokens", "250000", "--threshold-percent", "70", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.policy.thresholdTokens).toBe(250_000);
|
||||
expect(json.policy.thresholdPercent).toBe(70);
|
||||
expect(json.candidates.map((entry: { session: string }) => entry.session)).toEqual([
|
||||
"lower-token@test-rig",
|
||||
"lower-percent@test-rig",
|
||||
]);
|
||||
expect(json.candidates.find((entry: { session: string }) => entry.session === "lower-token@test-rig").thresholdReason).toBe("above_token_threshold");
|
||||
expect(json.candidates.find((entry: { session: string }) => entry.session === "lower-percent@test-rig").thresholdReason).toBe("above_percent_threshold_missing_window");
|
||||
});
|
||||
|
||||
it("rejects invalid threshold options before reading daemon inventory", async () => {
|
||||
const { deps, requestedPaths } = makeDeps();
|
||||
const cmd = compactPlanCommand(deps);
|
||||
|
||||
await cmd.parseAsync(["node", "rig", "--threshold-tokens", "0"]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join("\n")).toContain("--threshold-tokens");
|
||||
expect(requestedPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces stale context and missing attach or resume evidence as candidate caveats", async () => {
|
||||
const { deps } = makeDeps({
|
||||
nodesByRig: {
|
||||
"rig-0": [node({
|
||||
canonicalSessionName: "stale-no-attach@test-rig",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 90,
|
||||
remainingPercentage: 10,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: staleSample(),
|
||||
fresh: false,
|
||||
},
|
||||
})],
|
||||
},
|
||||
});
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const candidate = json.candidates[0];
|
||||
expect(candidate.contextFreshness).toBe("stale");
|
||||
expect(candidate.reasons).toContain("context_stale");
|
||||
expect(candidate.missingEvidence).toContain("fresh_context_sample");
|
||||
expect(candidate.missingEvidence).toContain("attach_or_resume_evidence");
|
||||
});
|
||||
|
||||
it("blocks unknown context and missing canonical session, and excludes them from recommended order", async () => {
|
||||
const { deps } = makeDeps({
|
||||
nodesByRig: {
|
||||
"rig-0": [
|
||||
node({
|
||||
logicalId: "unknown.context",
|
||||
canonicalSessionName: "unknown-context@test-rig",
|
||||
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
|
||||
}),
|
||||
node({
|
||||
logicalId: "missing.session",
|
||||
canonicalSessionName: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 90,
|
||||
remainingPercentage: 10,
|
||||
contextWindowSize: 1_000_000,
|
||||
source: "claude_statusline_json",
|
||||
availability: "known",
|
||||
sampledAt: freshSample(),
|
||||
fresh: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.candidates).toHaveLength(0);
|
||||
expect(json.recommendedOrder).toEqual([]);
|
||||
expect(json.blocked.find((entry: { logicalId: string }) => entry.logicalId === "unknown.context").reasons).toContain("context_unknown");
|
||||
const missingSession = json.blocked.find((entry: { logicalId: string }) => entry.logicalId === "missing.session");
|
||||
expect(missingSession.reasons).toContain("missing_canonical_session");
|
||||
expect(missingSession.notificationPacket.text).toContain("cannot safely plan");
|
||||
expect(missingSession.precompactRequirements).toContain("one_seat_at_a_time_only");
|
||||
});
|
||||
|
||||
it("does not recommend non-running or not-ready Claude seats", async () => {
|
||||
const { deps } = makeDeps({
|
||||
nodesByRig: {
|
||||
"rig-0": [
|
||||
node({ canonicalSessionName: "stopped@test-rig", sessionStatus: "stopped" }),
|
||||
node({ canonicalSessionName: "pending@test-rig", startupStatus: "pending" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.recommendedOrder).toEqual([]);
|
||||
expect(json.blocked.map((entry: { reasons: string[] }) => entry.reasons).flat()).toContain("seat_not_running_or_ready");
|
||||
});
|
||||
|
||||
it("--rig filters to one rig and unknown rig exits before refresh", async () => {
|
||||
const { deps, requestedPaths } = makeDeps({
|
||||
rigNames: ["rig-a", "rig-b"],
|
||||
nodesByRig: {
|
||||
"rig-0": [node({ rigId: "rig-0", rigName: "rig-a", canonicalSessionName: "lead@rig-a" })],
|
||||
"rig-1": [node({ rigId: "rig-1", rigName: "rig-b", canonicalSessionName: "lead@rig-b" })],
|
||||
},
|
||||
});
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "rig-b", "--json"]);
|
||||
let json = JSON.parse(logs.join(""));
|
||||
expect(json.candidates.map((entry: { session: string }) => entry.session)).toEqual(["lead@rig-b"]);
|
||||
|
||||
logs = [];
|
||||
errors = [];
|
||||
process.exitCode = undefined;
|
||||
const unknownCmd = compactPlanCommand(deps);
|
||||
await unknownCmd.parseAsync(["node", "rig", "--rig", "missing", "--refresh"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join(" ")).toContain("not found");
|
||||
expect(requestedPaths.some((path) => path.includes("refresh=true"))).toBe(false);
|
||||
});
|
||||
|
||||
it("--refresh calls refresh before inventory and hard-fails without printing a plan on refresh failure", async () => {
|
||||
const success = makeDeps();
|
||||
const cmd = compactPlanCommand(success.deps);
|
||||
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
|
||||
const nodeCalls = success.requestedPaths.filter((path) => path.includes("/nodes"));
|
||||
expect(nodeCalls[0]).toContain("refresh=true");
|
||||
expect(nodeCalls[1]).not.toContain("refresh=true");
|
||||
|
||||
logs = [];
|
||||
errors = [];
|
||||
process.exitCode = undefined;
|
||||
const failure = makeDeps({ refreshStatus: 502 });
|
||||
const failCmd = compactPlanCommand(failure.deps);
|
||||
await failCmd.parseAsync(["node", "rig", "--refresh"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(errors.join("\n")).toContain("Compact-plan refresh failed");
|
||||
expect(logs.join("\n")).not.toContain("READ-ONLY PLAN");
|
||||
});
|
||||
|
||||
it("daemon down exits nonzero with honest next step copy", async () => {
|
||||
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
|
||||
(getDaemonStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: "stopped", healthy: false });
|
||||
|
||||
const { deps } = makeDeps();
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig"]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join("\n")).toContain("Daemon is not running");
|
||||
expect(errors.join("\n")).toContain("rig daemon start");
|
||||
});
|
||||
|
||||
it("human output is explicit that this is a read-only triage plan, not compact permission", async () => {
|
||||
const { deps } = makeDeps();
|
||||
const cmd = compactPlanCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig"]);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("READ-ONLY PLAN");
|
||||
expect(output).toContain("does not compact");
|
||||
expect(output).toContain("Thresholds:");
|
||||
expect(output).toContain("one-seat-at-a-time");
|
||||
expect(output).toContain("orch-lead@test-rig");
|
||||
expect(output).toContain("authorization");
|
||||
expect(output).toContain("checkpoint/restore");
|
||||
expect(output).toContain("claude-compact-in-place");
|
||||
expect(output).toContain("No automatic compaction has been run");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { contextCommand, type ContextDeps } from "../src/commands/context.js";
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
daemonState?: string;
|
||||
rigNames?: string[];
|
||||
nodesByRig?: Record<string, unknown[]>;
|
||||
}): ContextDeps {
|
||||
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({
|
||||
rigId: `rig-${i}`, name, nodeCount: 1, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null,
|
||||
}));
|
||||
|
||||
const nodesByRig = overrides?.nodesByRig ?? {
|
||||
"rig-0": [
|
||||
{
|
||||
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.impl", canonicalSessionName: "dev-impl@test-rig",
|
||||
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
|
||||
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 85, remainingPercentage: 15, contextWindowSize: 1000000,
|
||||
source: "claude_statusline_json", availability: "known",
|
||||
sampledAt: new Date(Date.now() - 60_000).toISOString(), fresh: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.qa", canonicalSessionName: "dev-qa@test-rig",
|
||||
nodeKind: "agent", runtime: "codex", sessionStatus: "running", startupStatus: "ready",
|
||||
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
|
||||
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
|
||||
},
|
||||
{
|
||||
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.synth", canonicalSessionName: "dev-synth@test-rig",
|
||||
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
|
||||
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 50, remainingPercentage: 50, contextWindowSize: 1000000,
|
||||
source: "claude_statusline_json", availability: "known",
|
||||
sampledAt: new Date(Date.now() - 3600_000).toISOString(), fresh: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path === "/api/ps") return { status: 200, data: rigs };
|
||||
for (const [key, nodes] of Object.entries(nodesByRig)) {
|
||||
if (path.includes(key)) return { status: 200, data: nodes };
|
||||
}
|
||||
return { status: 200, data: [] };
|
||||
}),
|
||||
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
// Patch getDaemonStatus + getDaemonUrl for tests
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("rig context", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("human output includes seat table with status + fleet summary", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig"]);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("CONTEXT USAGE");
|
||||
expect(output).toContain("dev-impl@test-rig");
|
||||
expect(output).toContain("CRITICAL");
|
||||
expect(output).toContain("dev-qa@test-rig");
|
||||
expect(output).toContain("unknown");
|
||||
expect(output).toContain("dev-synth@test-rig");
|
||||
expect(output).toContain("stale");
|
||||
expect(output).toContain("FLEET SUMMARY:");
|
||||
});
|
||||
|
||||
it("JSON output includes seats array + summary with correct fields", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.seats).toBeInstanceOf(Array);
|
||||
expect(json.seats.length).toBe(3);
|
||||
expect(json.summary).toBeDefined();
|
||||
expect(json.summary.total).toBe(3);
|
||||
|
||||
const critical = json.seats.find((s: { session: string }) => s.session === "dev-impl@test-rig");
|
||||
expect(critical.urgency).toBe("critical");
|
||||
expect(critical.freshness).toBe("fresh");
|
||||
expect(critical.status).toBe("critical");
|
||||
expect(critical.usedPercentage).toBe(85);
|
||||
|
||||
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
|
||||
expect(unknown.urgency).toBe("unknown");
|
||||
expect(unknown.status).toBe("unknown");
|
||||
|
||||
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
|
||||
expect(stale.urgency).toBe("low");
|
||||
expect(stale.freshness).toBe("stale");
|
||||
expect(stale.status).toBe("stale");
|
||||
});
|
||||
|
||||
it("sort order: critical > warning > stale > ok > unknown", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const sessions = json.seats.map((s: { session: string }) => s.session);
|
||||
// critical first, then stale (low+stale), then unknown
|
||||
expect(sessions).toEqual(["dev-impl@test-rig", "dev-synth@test-rig", "dev-qa@test-rig"]);
|
||||
});
|
||||
|
||||
it("--rig filters to one rig", async () => {
|
||||
const cmd = contextCommand(makeDeps({ rigNames: ["rig-a", "rig-b"] }));
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "rig-a", "--json"]);
|
||||
|
||||
const output = logs.join("\n");
|
||||
// Should not error — even if node data is empty for the filtered rig
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--rig with unknown rig name exits 1", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "nonexistent"]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join(" ")).toContain("not found");
|
||||
});
|
||||
|
||||
it("--threshold filters seats but keeps unknown and stale visible", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--threshold", "80", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const sessions = json.seats.map((s: { session: string }) => s.session);
|
||||
// 85% critical (above threshold) + 50% stale (always visible) + unknown (always visible)
|
||||
expect(sessions).toContain("dev-impl@test-rig");
|
||||
expect(sessions).toContain("dev-synth@test-rig"); // stale — always visible
|
||||
expect(sessions).toContain("dev-qa@test-rig"); // unknown — always visible
|
||||
});
|
||||
|
||||
it("--threshold abc rejects with nonzero exit", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--threshold", "abc"]);
|
||||
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(errors.join(" ")).toContain("integer percentage");
|
||||
});
|
||||
|
||||
it("--threshold -1 rejects with nonzero exit", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--threshold", "-1"]);
|
||||
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("low + fresh = ok; low + stale = stale (never ok)", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
|
||||
// 50% (low urgency) + stale freshness => status must be "stale", NOT "ok"
|
||||
expect(stale.status).toBe("stale");
|
||||
expect(stale.displayStatus).toBe("stale");
|
||||
});
|
||||
|
||||
it("unknown seats have honest unknown status", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
|
||||
expect(unknown.usedPercentage).toBeNull();
|
||||
expect(unknown.urgency).toBe("unknown");
|
||||
expect(unknown.status).toBe("unknown");
|
||||
expect(unknown.fresh).toBe(false);
|
||||
});
|
||||
|
||||
// --- --refresh CLI tests ---
|
||||
|
||||
it("--refresh calls /nodes?refresh=true once then fetches inventory normally", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
const deps: ContextDeps = {
|
||||
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
requestedPaths.push(path);
|
||||
if (path === "/api/ps") return { status: 200, data: [{ rigId: "rig-0", name: "test-rig" }] };
|
||||
return { status: 200, data: [] };
|
||||
}),
|
||||
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
|
||||
};
|
||||
const cmd = contextCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
|
||||
|
||||
// First non-ps call should be refresh, then normal inventory
|
||||
const nodeCalls = requestedPaths.filter((p) => p.includes("/nodes"));
|
||||
expect(nodeCalls.length).toBe(2);
|
||||
expect(nodeCalls[0]).toContain("refresh=true");
|
||||
expect(nodeCalls[1]).not.toContain("refresh=true");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--refresh failure exits 2 with honest error copy", async () => {
|
||||
const deps: ContextDeps = {
|
||||
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path === "/api/ps") return { status: 200, data: [{ rigId: "rig-0", name: "test-rig" }] };
|
||||
if (path.includes("refresh=true")) return { status: 502, data: { code: "context_refresh_failed", error: "statusline read failed" } };
|
||||
return { status: 200, data: [] };
|
||||
}),
|
||||
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
|
||||
};
|
||||
const cmd = contextCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--refresh"]);
|
||||
|
||||
expect(process.exitCode).toBe(2);
|
||||
const errorOutput = errors.join("\n");
|
||||
expect(errorOutput).toContain("Context refresh failed");
|
||||
expect(errorOutput).toContain("Fix:");
|
||||
});
|
||||
|
||||
it("--refresh success returns same JSON semantics as non-refresh", async () => {
|
||||
const deps = makeDeps();
|
||||
const cmd = contextCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.seats).toBeInstanceOf(Array);
|
||||
expect(json.summary).toBeDefined();
|
||||
// Should have the same structure as non-refresh output
|
||||
expect(json.summary.total).toBeDefined();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,557 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { sendCommand, type SendDeps } from "../src/commands/send.js";
|
||||
import { captureCommand, type CaptureDeps } from "../src/commands/capture.js";
|
||||
import { psCommand, type PsDeps } from "../src/commands/ps.js";
|
||||
import { whoamiCommand, type WhoamiDeps } from "../src/commands/whoami.js";
|
||||
import type { CrossHostResult } from "../src/cross-host-executor.js";
|
||||
import type { HostRegistryLoadResult } from "../src/host-registry.js";
|
||||
|
||||
// Capture stdout / stderr / exitCode for command actions.
|
||||
interface CapturedOutput {
|
||||
stdoutLines: string[];
|
||||
stderrLines: string[];
|
||||
stdoutWrites: string[];
|
||||
stderrWrites: string[];
|
||||
exitCode: number | undefined;
|
||||
}
|
||||
|
||||
let captured: CapturedOutput;
|
||||
let originalLog: typeof console.log;
|
||||
let originalError: typeof console.error;
|
||||
let originalStdoutWrite: typeof process.stdout.write;
|
||||
let originalStderrWrite: typeof process.stderr.write;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = { stdoutLines: [], stderrLines: [], stdoutWrites: [], stderrWrites: [], exitCode: undefined };
|
||||
originalLog = console.log;
|
||||
originalError = console.error;
|
||||
originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
console.log = (...args: unknown[]) => { captured.stdoutLines.push(args.map(String).join(" ")); };
|
||||
console.error = (...args: unknown[]) => { captured.stderrLines.push(args.map(String).join(" ")); };
|
||||
process.stdout.write = ((s: string | Uint8Array) => { captured.stdoutWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stdout.write;
|
||||
process.stderr.write = ((s: string | Uint8Array) => { captured.stderrWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stderr.write;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
captured.exitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
const KNOWN_REGISTRY: HostRegistryLoadResult = {
|
||||
ok: true,
|
||||
registry: {
|
||||
hosts: [{ id: "vm-a", transport: "ssh", target: "vm-a.local" }],
|
||||
},
|
||||
};
|
||||
|
||||
function deps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): SendDeps & CaptureDeps {
|
||||
const lifecycleDeps = {} as never;
|
||||
const clientFactory = (() => ({}) as never) as never;
|
||||
return {
|
||||
lifecycleDeps,
|
||||
clientFactory,
|
||||
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
|
||||
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `rig send --host <id>`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("send --host (cross-host short-circuit)", () => {
|
||||
it("happy path: forwards reconstructed argv to the executor and surfaces remote stdout verbatim", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = sendCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: yes\n", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hello world", "--verify"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "send", "dev-impl@my-rig", "hello world", "--verify"]);
|
||||
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
|
||||
const stdoutText = captured.stdoutWrites.join("");
|
||||
expect(stdoutText).toContain("Verified: yes");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--verify honesty: SSH success + remote 'Verified: no' surfaces in output, NOT collapsed into success+silence", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: no\n", stderr: "", remoteExitCode: 0 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hi", "--verify"], { from: "user" });
|
||||
const stdoutText = captured.stdoutWrites.join("");
|
||||
expect(stdoutText).toContain("Verified: no"); // remote authority surfaced
|
||||
// ssh layer succeeded; CLI does NOT inject Verified: yes.
|
||||
expect(stdoutText).not.toMatch(/^Verified: yes$/m);
|
||||
});
|
||||
|
||||
it("forwards --force, --wait-for-idle, --json flags into the remote argv", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = sendCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--force", "--json"], { from: "user" });
|
||||
expect(captureCalls.argv).toContain("--force");
|
||||
expect(captureCalls.argv).toContain("--json");
|
||||
});
|
||||
|
||||
it("JSON output wraps the executor result in a cross_host envelope", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
|
||||
const out = JSON.parse(captured.stdoutLines[0]!);
|
||||
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
||||
expect(out.result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown host id surfaces a friendly error with discoverability hint", async () => {
|
||||
const cmd = sendCommand(deps());
|
||||
await cmd.parseAsync(["--host", "vm-unknown", "s@r", "msg"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-unknown'");
|
||||
expect(captured.stderrLines.join("\n")).toContain("vm-a"); // known-id hint
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("registry load failure surfaces the error", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
registry: { ok: false, error: "host registry not found at /nope" },
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("host registry not found");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("ssh-unreachable failure → exit 1 with operator-actionable message", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect to host: Connection refused" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
||||
expect(captured.stderrLines.join("\n")).toContain("Verify SSH access");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("permission-gate failure → exit 1 with field-note hint", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
|
||||
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("remote-daemon-unreachable → exit 1 with daemon-start hint", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
|
||||
expect(captured.stderrLines.join("\n")).toContain("rig daemon start");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("remote-command-failed → exit 1 with remote stderr surfaced", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "session not found", remoteExitCode: 3 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
|
||||
expect(captured.stderrLines.join("\n")).toContain("session not found");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("JSON output for failure surfaces failedStep + permission-gate hint under result", async () => {
|
||||
const cmd = sendCommand(deps({
|
||||
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
|
||||
const out = JSON.parse(captured.stdoutLines[0]!);
|
||||
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
||||
expect(out.result.ok).toBe(false);
|
||||
expect(out.result.failedStep).toBe("permission-gate");
|
||||
expect(out.result.hint).toBe("See keychain doc");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `rig capture --host <id>`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("capture --host (cross-host short-circuit)", () => {
|
||||
it("happy path: forwards reconstructed argv with session + lines", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "pane content here\n", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines", "50"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "50"]);
|
||||
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
|
||||
expect(captured.stdoutWrites.join("")).toContain("pane content here");
|
||||
});
|
||||
|
||||
it("multi-target form: forwards --rig and --pod when no session is supplied", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "--rig", "my-rig", "--pod", "dev", "--json"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "--rig", "my-rig", "--pod", "dev", "--lines", "20", "--json"]);
|
||||
});
|
||||
|
||||
it("unknown host id surfaces a friendly error", async () => {
|
||||
const cmd = captureCommand(deps());
|
||||
await cmd.parseAsync(["--host", "vm-bogus", "s@r"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("ssh-unreachable failure surfaces actionable error", async () => {
|
||||
const cmd = captureCommand(deps({
|
||||
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("JSON output wraps result in cross_host envelope", async () => {
|
||||
const cmd = captureCommand(deps({
|
||||
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "s@r", "--json"], { from: "user" });
|
||||
const out = JSON.parse(captured.stdoutLines[0]!);
|
||||
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
||||
expect(out.result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compat regression: `rig send` and `rig capture` WITHOUT --host MUST NOT add
|
||||
// the cross-host annotation and MUST NOT call the executor at all.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("compat regression: no --host means no cross-host annotation", () => {
|
||||
it("send help text mentions --host (so operators discover it)", () => {
|
||||
const cmd = sendCommand(deps());
|
||||
expect(cmd.helpInformation()).toContain("--host");
|
||||
});
|
||||
|
||||
it("capture help text mentions --host", () => {
|
||||
const cmd = captureCommand(deps());
|
||||
expect(cmd.helpInformation()).toContain("--host");
|
||||
});
|
||||
|
||||
it("send without --host does NOT call the cross-host executor (registry loader untouched, executor untouched)", async () => {
|
||||
const loaderSpy = vi.fn();
|
||||
const runSpy = vi.fn();
|
||||
const cmd = sendCommand({
|
||||
lifecycleDeps: makeFailingLifecycleDeps(),
|
||||
clientFactory: () => ({} as never),
|
||||
hostRegistryLoader: loaderSpy as never,
|
||||
crossHostRun: runSpy as never,
|
||||
});
|
||||
await cmd.parseAsync(["s@r", "msg"], { from: "user" });
|
||||
// local path short-circuits at "Daemon not running" without consulting cross-host machinery.
|
||||
expect(loaderSpy).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("capture without --host does NOT call the cross-host executor", async () => {
|
||||
const loaderSpy = vi.fn();
|
||||
const runSpy = vi.fn();
|
||||
const cmd = captureCommand({
|
||||
lifecycleDeps: makeFailingLifecycleDeps(),
|
||||
clientFactory: () => ({} as never),
|
||||
hostRegistryLoader: loaderSpy as never,
|
||||
crossHostRun: runSpy as never,
|
||||
});
|
||||
await cmd.parseAsync(["s@r"], { from: "user" });
|
||||
expect(loaderSpy).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function makeFailingLifecycleDeps(): never {
|
||||
// Stub LifecycleDeps that responds with "no daemon" on every probe — the
|
||||
// local-path branch short-circuits with "Daemon not running" before doing
|
||||
// anything cross-host.
|
||||
return {
|
||||
exists: (_p: string) => false,
|
||||
readFileText: (_p: string) => "",
|
||||
spawnDetached: () => ({ pid: 0 }),
|
||||
isPidAlive: (_p: number) => false,
|
||||
fetchHealthDetail: async () => ({ ok: false, code: "unreachable" }),
|
||||
} as never;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `rig ps --host <id>` (C9b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function psDeps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): PsDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as never,
|
||||
clientFactory: (() => ({}) as never) as never,
|
||||
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
|
||||
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ps --host (cross-host short-circuit)", () => {
|
||||
it("argv reconstruction: forwards every shaping flag in operator-declared order", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: '[{"rigName":"r1"}]\n', stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync([
|
||||
"--host", "vm-a", "--nodes", "--full", "--limit", "20",
|
||||
"--fields", "rigName,status", "--summary", "--filter", "status=running", "--json",
|
||||
], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual([
|
||||
"rig", "ps", "--nodes", "--full", "--limit", "20", "--fields", "rigName,status",
|
||||
"--summary", "--filter", "status=running", "--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("argv reconstruction: bare `--host vm-a` produces ['rig','ps'] with no extra flags", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "ps"]);
|
||||
});
|
||||
|
||||
it("verbatim remote stdout passthrough on success — no double-JSON-wrapping", async () => {
|
||||
const remoteJson = '[{"rigName":"r1","status":"running"},{"rigName":"r2","status":"stopped"}]\n';
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async () => ({ ok: true, failedStep: "none", stdout: remoteJson, stderr: "", remoteExitCode: 0 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
||||
const stdoutText = captured.stdoutWrites.join("");
|
||||
// Output is verbatim remote stdout; NOT wrapped in {cross_host:..., result:...}.
|
||||
expect(stdoutText).toBe(remoteJson);
|
||||
expect(stdoutText).not.toContain("cross_host");
|
||||
});
|
||||
|
||||
it("ssh-unreachable failure surfaces actionable error and exits 1", async () => {
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect refused" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("permission-gate failure surfaces hint and exits 1", async () => {
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
|
||||
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("remote-daemon-unreachable surfaces daemon-start hint", async () => {
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("remote-command-failed surfaces remote stderr", async () => {
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "Some rig error", remoteExitCode: 3 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("unknown host id surfaces friendly error with discoverability hint", async () => {
|
||||
const cmd = psCommand(psDeps());
|
||||
await cmd.parseAsync(["--host", "vm-bogus"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("JSON output for failure surfaces top-level failedStep envelope", async () => {
|
||||
const cmd = psCommand(psDeps({
|
||||
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
||||
const out = JSON.parse(captured.stdoutLines[0]!);
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
||||
expect(out.failedStep).toBe("permission-gate");
|
||||
expect(out.hint).toBe("See keychain doc");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("help text mentions --host (operator discoverability)", () => {
|
||||
const cmd = psCommand(psDeps());
|
||||
expect(cmd.helpInformation()).toContain("--host");
|
||||
});
|
||||
|
||||
it("compat regression: ps without --host does NOT call registry loader OR executor", async () => {
|
||||
const loaderSpy = vi.fn();
|
||||
const runSpy = vi.fn();
|
||||
const cmd = psCommand({
|
||||
lifecycleDeps: makeFailingLifecycleDeps(),
|
||||
clientFactory: () => ({} as never),
|
||||
hostRegistryLoader: loaderSpy as never,
|
||||
crossHostRun: runSpy as never,
|
||||
});
|
||||
await cmd.parseAsync([], { from: "user" });
|
||||
expect(loaderSpy).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `rig whoami --host <id>` (C9b')
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function whoamiDeps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): WhoamiDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as never,
|
||||
clientFactory: (() => ({}) as never) as never,
|
||||
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
|
||||
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("whoami --host (cross-host short-circuit)", () => {
|
||||
it("argv reconstruction: forwards --node-id, --session, --json in operator-declared order", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = whoamiCommand(whoamiDeps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync([
|
||||
"--host", "vm-a", "--node-id", "01ABC", "--session", "foo@rig", "--json",
|
||||
], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual([
|
||||
"rig", "whoami", "--node-id", "01ABC", "--session", "foo@rig", "--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("argv reconstruction: bare `--host vm-a` produces ['rig','whoami'] with no extra flags", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = whoamiCommand(whoamiDeps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "whoami"]);
|
||||
});
|
||||
|
||||
it("cross-host runs BEFORE local identity-source resolution (no local --node-id/--session required)", async () => {
|
||||
// Critical for whoami: the local resolveIdentitySource() walks env vars and
|
||||
// tmux pane metadata. Cross-host must NOT require any of that — the remote
|
||||
// rig has its own identity context.
|
||||
const cmd = whoamiCommand(whoamiDeps({
|
||||
run: async () => ({ ok: true, failedStep: "none", stdout: '{"rigName":"remote"}\n', stderr: "", remoteExitCode: 0 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
||||
// Should NOT have errored on "Cannot determine identity"; should have
|
||||
// successfully written remote stdout.
|
||||
expect(captured.stderrLines.join("\n")).not.toContain("Cannot determine identity");
|
||||
const stdoutText = captured.stdoutWrites.join("");
|
||||
expect(stdoutText).toContain("remote");
|
||||
});
|
||||
|
||||
it("verbatim remote stdout passthrough on success — no double-JSON-wrapping", async () => {
|
||||
const remoteJson = '{"resolvedBy":"node_id","identity":{"rigName":"x"}}\n';
|
||||
const cmd = whoamiCommand(whoamiDeps({
|
||||
run: async () => ({ ok: true, failedStep: "none", stdout: remoteJson, stderr: "", remoteExitCode: 0 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
||||
const stdoutText = captured.stdoutWrites.join("");
|
||||
expect(stdoutText).toBe(remoteJson);
|
||||
expect(stdoutText).not.toContain("cross_host");
|
||||
});
|
||||
|
||||
it("ssh-unreachable failure exits 1 with actionable message", async () => {
|
||||
const cmd = whoamiCommand(whoamiDeps({
|
||||
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("unknown host id surfaces friendly error", async () => {
|
||||
const cmd = whoamiCommand(whoamiDeps());
|
||||
await cmd.parseAsync(["--host", "vm-bogus"], { from: "user" });
|
||||
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("JSON output for failure surfaces top-level failedStep envelope", async () => {
|
||||
const cmd = whoamiCommand(whoamiDeps({
|
||||
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
||||
const out = JSON.parse(captured.stdoutLines[0]!);
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
||||
expect(out.failedStep).toBe("remote-daemon-unreachable");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("help text mentions --host", () => {
|
||||
const cmd = whoamiCommand(whoamiDeps());
|
||||
expect(cmd.helpInformation()).toContain("--host");
|
||||
});
|
||||
|
||||
it("compat regression: whoami without --host does NOT call registry loader OR executor", async () => {
|
||||
const loaderSpy = vi.fn();
|
||||
const runSpy = vi.fn();
|
||||
const cmd = whoamiCommand({
|
||||
lifecycleDeps: makeFailingLifecycleDeps(),
|
||||
clientFactory: () => ({} as never),
|
||||
hostRegistryLoader: loaderSpy as never,
|
||||
crossHostRun: runSpy as never,
|
||||
});
|
||||
// Passing --node-id avoids the "cannot determine identity" branch; the
|
||||
// local daemon-down path runs after that (or partial-result; either way
|
||||
// does NOT consult the cross-host machinery).
|
||||
await cmd.parseAsync(["--node-id", "abc"], { from: "user" });
|
||||
expect(loaderSpy).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
runCrossHostCommand,
|
||||
classifyResult,
|
||||
shellQuote,
|
||||
type SpawnFn,
|
||||
} from "../src/cross-host-executor.js";
|
||||
import type { HostEntry } from "../src/host-registry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock spawn helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MockChild extends EventEmitter {
|
||||
stdin: { write: (s: string) => void; end: () => void };
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
|
||||
function makeMockChild(): MockChild {
|
||||
const child = new EventEmitter() as MockChild;
|
||||
child.stdin = { write: vi.fn(), end: vi.fn() };
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
return child;
|
||||
}
|
||||
|
||||
function mockSpawnFor(opts: {
|
||||
exitCode: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
capture?: { command?: string; args?: readonly string[] };
|
||||
}): SpawnFn {
|
||||
return ((command: string, args: readonly string[]) => {
|
||||
if (opts.capture) {
|
||||
opts.capture.command = command;
|
||||
opts.capture.args = args;
|
||||
}
|
||||
const child = makeMockChild();
|
||||
queueMicrotask(() => {
|
||||
if (opts.stdout) child.stdout.emit("data", Buffer.from(opts.stdout, "utf-8"));
|
||||
if (opts.stderr) child.stderr.emit("data", Buffer.from(opts.stderr, "utf-8"));
|
||||
child.emit("close", opts.exitCode);
|
||||
});
|
||||
return child as never;
|
||||
}) as SpawnFn;
|
||||
}
|
||||
|
||||
const HOST: HostEntry = { id: "vm-test", transport: "ssh", target: "vm-test.local" };
|
||||
const HOST_WITH_USER: HostEntry = { id: "vm-test", transport: "ssh", target: "vm-test.local", user: "ops" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shellQuote unit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("shellQuote", () => {
|
||||
it("wraps simple strings in single quotes", () => {
|
||||
expect(shellQuote("hello")).toBe("'hello'");
|
||||
});
|
||||
it("escapes embedded single quotes via the standard '\\'' trick", () => {
|
||||
expect(shellQuote("it's fine")).toBe("'it'\\''s fine'");
|
||||
});
|
||||
it("preserves spaces, equals signs, and special chars literally inside the quotes", () => {
|
||||
expect(shellQuote("a b=c d?e")).toBe("'a b=c d?e'");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyResult — unit table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("classifyResult", () => {
|
||||
it("exit 0 → ok / failedStep=none", () => {
|
||||
const r = classifyResult(0, "Sent to dev-impl@rig\nVerified: yes\n", "");
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
failedStep: "none",
|
||||
stdout: "Sent to dev-impl@rig\nVerified: yes\n",
|
||||
stderr: "",
|
||||
remoteExitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("exit 255 with Permission denied → permission-gate (with field-note hint)", () => {
|
||||
const r = classifyResult(255, "", "ssh: Permission denied (publickey).");
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.failedStep).toBe("permission-gate");
|
||||
if (r.failedStep === "permission-gate") {
|
||||
expect(r.sshStderr).toContain("Permission denied");
|
||||
expect(r.hint).toContain("Keychain-over-SSH");
|
||||
}
|
||||
});
|
||||
|
||||
it("exit 255 with Keychain pattern → permission-gate", () => {
|
||||
const r = classifyResult(255, "", "Could not query Keychain entry for ...");
|
||||
expect(r.failedStep).toBe("permission-gate");
|
||||
});
|
||||
|
||||
it("exit 255 with unknown stderr → ssh-unreachable", () => {
|
||||
const r = classifyResult(255, "", "ssh: connect to host vm-test.local port 22: Connection refused");
|
||||
expect(r.failedStep).toBe("ssh-unreachable");
|
||||
if (r.failedStep === "ssh-unreachable") {
|
||||
expect(r.sshStderr).toContain("Connection refused");
|
||||
}
|
||||
});
|
||||
|
||||
it("exit 1 with Daemon-not-running stderr → remote-daemon-unreachable", () => {
|
||||
const r = classifyResult(1, "", "Daemon not running. Start it with: rig daemon start\n");
|
||||
expect(r.failedStep).toBe("remote-daemon-unreachable");
|
||||
if (r.failedStep === "remote-daemon-unreachable") {
|
||||
expect(r.remoteExitCode).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("exit 2 with daemon-fetch-failure stderr → remote-daemon-unreachable", () => {
|
||||
const r = classifyResult(2, "", "Failed to fetch rig list from daemon (HTTP 500). Check daemon status with: rig status\n");
|
||||
expect(r.failedStep).toBe("remote-daemon-unreachable");
|
||||
});
|
||||
|
||||
it("exit non-zero without daemon signal → remote-command-failed", () => {
|
||||
const r = classifyResult(1, "", "Send failed: session not found\n");
|
||||
expect(r.failedStep).toBe("remote-command-failed");
|
||||
if (r.failedStep === "remote-command-failed") {
|
||||
expect(r.remoteExitCode).toBe(1);
|
||||
expect(r.stderr).toContain("session not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("exit -1 (spawn error) → ssh-unreachable", () => {
|
||||
const r = classifyResult(-1, "", "[spawn error] ENOENT");
|
||||
expect(r.failedStep).toBe("ssh-unreachable");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runCrossHostCommand — integration with mocked spawn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("runCrossHostCommand", () => {
|
||||
it("constructs the ssh argv with ConnectTimeout and quoted remote command line", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, stdout: "ok\n", capture });
|
||||
await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hello world", "--verify"], { spawn });
|
||||
expect(capture.command).toBe("ssh");
|
||||
expect(capture.args).toEqual([
|
||||
"-o", "ConnectTimeout=10",
|
||||
"vm-test.local",
|
||||
"'rig' 'send' 'dev-impl@rig' 'hello world' '--verify'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes -l <user> when host.user is set", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
|
||||
expect(capture.args?.[0]).toBe("-l");
|
||||
expect(capture.args?.[1]).toBe("ops");
|
||||
});
|
||||
|
||||
it("happy path returns stdout/stderr verbatim", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
stdout: "Sent to dev-impl@rig\nVerified: yes\n",
|
||||
stderr: "",
|
||||
});
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hello"], { spawn });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.stdout).toContain("Verified: yes");
|
||||
expect(result.remoteExitCode).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("--verify honest pass-through: ssh exit 0 + remote 'Verified: no' surfaces in stdout (NOT collapsed)", async () => {
|
||||
// This is the slice's load-bearing rule: ssh success is NOT verify success.
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
stdout: "Sent to dev-impl@rig\nVerified: no\n",
|
||||
});
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hi", "--verify"], { spawn });
|
||||
expect(result.ok).toBe(true); // ssh layer succeeded
|
||||
if (result.ok) {
|
||||
// remote verify result is in stdout, not synthesized into ok=false.
|
||||
// Callers (send command) are responsible for surfacing 'Verified: no' to the user.
|
||||
expect(result.stdout).toContain("Verified: no");
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies 255 + Permission denied as permission-gate", async () => {
|
||||
const spawn = mockSpawnFor({ exitCode: 255, stderr: "ssh: Permission denied (publickey)." });
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "ps"], { spawn });
|
||||
expect(result.failedStep).toBe("permission-gate");
|
||||
});
|
||||
|
||||
it("classifies 255 + connection refused as ssh-unreachable", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 255,
|
||||
stderr: "ssh: connect to host vm-test.local port 22: Connection refused",
|
||||
});
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "ps"], { spawn });
|
||||
expect(result.failedStep).toBe("ssh-unreachable");
|
||||
});
|
||||
|
||||
it("classifies remote daemon-not-running as remote-daemon-unreachable", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 1,
|
||||
stderr: "Daemon not running. Start it with: rig daemon start\n",
|
||||
});
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "send", "x", "y"], { spawn });
|
||||
expect(result.failedStep).toBe("remote-daemon-unreachable");
|
||||
});
|
||||
|
||||
it("classifies other remote non-zero as remote-command-failed", async () => {
|
||||
const spawn = mockSpawnFor({ exitCode: 3, stderr: "Some other rig error\n" });
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "send", "x", "y"], { spawn });
|
||||
expect(result.failedStep).toBe("remote-command-failed");
|
||||
if (result.failedStep === "remote-command-failed") {
|
||||
expect(result.remoteExitCode).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-ssh transport (defense-in-depth on top of registry validation)", async () => {
|
||||
const result = await runCrossHostCommand(
|
||||
{ id: "vm-x", transport: "tailscale" as never, target: "vm-x.local" } as HostEntry,
|
||||
["rig", "ps"],
|
||||
{ spawn: mockSpawnFor({ exitCode: 0 }) },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok && result.failedStep === "ssh-unreachable") {
|
||||
expect(result.sshStderr).toContain("not supported");
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards stdin to the spawned process when provided", async () => {
|
||||
const writes: string[] = [];
|
||||
let stdinEnded = false;
|
||||
const spawn: SpawnFn = ((command: string, _args: readonly string[]) => {
|
||||
const child = makeMockChild();
|
||||
child.stdin.write = (s: string) => writes.push(s);
|
||||
child.stdin.end = () => { stdinEnded = true; };
|
||||
queueMicrotask(() => child.emit("close", 0));
|
||||
return child as never;
|
||||
}) as SpawnFn;
|
||||
await runCrossHostCommand(HOST, ["cat"], { spawn, stdin: "from-stdin\n" });
|
||||
expect(writes).toEqual(["from-stdin\n"]);
|
||||
expect(stdinEnded).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom connect timeout", async () => {
|
||||
const capture: { args?: readonly string[] } = {};
|
||||
await runCrossHostCommand(HOST, ["rig", "ps"], {
|
||||
spawn: mockSpawnFor({ exitCode: 0, capture }),
|
||||
connectTimeoutSeconds: 30,
|
||||
});
|
||||
expect(capture.args).toContain("ConnectTimeout=30");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
tailLogs,
|
||||
getDaemonPath,
|
||||
resolveDaemonPath,
|
||||
buildDaemonEnv,
|
||||
STATE_FILE,
|
||||
LOG_FILE,
|
||||
OPENRIG_DIR,
|
||||
@@ -36,6 +37,22 @@ function mockDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
};
|
||||
}
|
||||
|
||||
function startableDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
let spawned = false;
|
||||
const spawn = overrides?.spawn ?? vi.fn(() => {
|
||||
spawned = true;
|
||||
return { pid: 12345, unref: vi.fn() } as unknown as ChildProcess;
|
||||
});
|
||||
return mockDeps({
|
||||
...overrides,
|
||||
spawn,
|
||||
fetch: overrides?.fetch ?? vi.fn(async () => {
|
||||
if (!spawned) throw new Error("refused");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function writtenState(deps: LifecycleDeps): DaemonState {
|
||||
const call = (deps.writeFile as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(c: unknown[]) => (c[0] as string).endsWith("daemon.json")
|
||||
@@ -54,7 +71,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 2: start constructs exact spawn command with correct env/redirect
|
||||
it("start: constructs spawn with node, daemon entry, env, and log redirect", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
|
||||
|
||||
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
|
||||
@@ -73,7 +90,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt
|
||||
it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
const result = await startDaemon({ port: 8000, db: "test.sqlite" }, deps);
|
||||
|
||||
expect(result.pid).toBe(12345);
|
||||
@@ -102,6 +119,19 @@ describe("Daemon Lifecycle", () => {
|
||||
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
|
||||
});
|
||||
|
||||
it("start: recovered running daemon without daemon.json -> throws instead of spawning duplicate", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7433/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 5: stop reads pid from daemon.json, sends SIGTERM, removes daemon.json
|
||||
it("stop: reads pid, sends SIGTERM, removes daemon.json", async () => {
|
||||
const state: DaemonState = { pid: 555, port: 7433, db: "openrig.sqlite", startedAt: "2026-01-01T00:00:00Z" };
|
||||
@@ -126,12 +156,26 @@ describe("Daemon Lifecycle", () => {
|
||||
it("stop: not running -> does not throw", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await expect(stopDaemon(deps)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stop: recovered running daemon without daemon.json -> throws honest error", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7433/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(stopDaemon(deps)).rejects.toThrow(/state is missing|cannot stop safely/i);
|
||||
expect(deps.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 7: status reads port from daemon.json, reports running with port
|
||||
it("status: running daemon (pid alive + healthz ok) -> { state: 'running', port, pid }", async () => {
|
||||
const state: DaemonState = { pid: 777, port: 9000, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
|
||||
@@ -157,12 +201,42 @@ describe("Daemon Lifecycle", () => {
|
||||
it("status: no daemon.json -> { state: 'stopped' }", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
const status = await getDaemonStatus(deps);
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("status: no daemon.json but configured healthz responds -> running without pid", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = "7555";
|
||||
process.env["OPENRIG_HOST"] = "127.0.0.1";
|
||||
try {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7555/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await getDaemonStatus(deps);
|
||||
expect(status).toEqual({
|
||||
state: "running",
|
||||
host: "127.0.0.1",
|
||||
port: 7555,
|
||||
healthy: true,
|
||||
});
|
||||
} finally {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
// Test 9: status stale (daemon.json exists, process dead) -> reports stale, cleans up
|
||||
it("status: stale (daemon.json exists, pid dead) -> { state: 'stale' }, cleans up", async () => {
|
||||
const state: DaemonState = { pid: 888, port: 7433, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
|
||||
@@ -182,7 +256,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 10: start --port flag stored in daemon.json and forwarded to env
|
||||
it("start: custom port stored in daemon.json and forwarded to spawn env", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 9999 }, deps);
|
||||
|
||||
const state = writtenState(deps);
|
||||
@@ -232,7 +306,8 @@ describe("Daemon Lifecycle", () => {
|
||||
const { Command } = await import("commander");
|
||||
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false), // no daemon.json -> stopped
|
||||
exists: vi.fn(() => false), // no daemon.json + no healthy daemon -> stopped
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
@@ -255,9 +330,74 @@ describe("Daemon Lifecycle", () => {
|
||||
expect(logs.join("\n")).toMatch(/stopped/i);
|
||||
});
|
||||
|
||||
it("daemonCommand status omits pid when daemon is recovered without state", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = "7555";
|
||||
process.env["OPENRIG_HOST"] = "127.0.0.1";
|
||||
try {
|
||||
const { daemonCommand } = await import("../src/commands/daemon.js");
|
||||
const { Command } = await import("commander");
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(daemonCommand(deps));
|
||||
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", "daemon", "status"]);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Daemon running on port 7555");
|
||||
expect(output).not.toContain("pid undefined");
|
||||
} finally {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
it("daemonCommand stop surfaces missing-state recovery error instead of claiming success", async () => {
|
||||
const { daemonCommand } = await import("../src/commands/daemon.js");
|
||||
const { Command } = await import("commander");
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(daemonCommand(deps));
|
||||
|
||||
const logs: string[] = [];
|
||||
const origErr = console.error;
|
||||
const savedExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", "daemon", "stop"]);
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
expect(logs.join("\n")).toMatch(/state is missing|cannot stop safely/i);
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = savedExitCode;
|
||||
});
|
||||
|
||||
// Test 15: start creates ~/.openrig directory if missing
|
||||
it("start: creates OPENRIG_DIR if missing", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433 }, deps);
|
||||
|
||||
expect(deps.mkdirp).toHaveBeenCalledWith(OPENRIG_DIR);
|
||||
@@ -440,6 +580,7 @@ describe("Daemon Lifecycle", () => {
|
||||
if (p === STATE_FILE) return "NOT VALID JSON {{{";
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
const status = await getDaemonStatus(deps);
|
||||
@@ -448,7 +589,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 22: malformed daemon.json -> startDaemon proceeds (treats as no state)
|
||||
it("malformed daemon.json -> startDaemon proceeds normally", async () => {
|
||||
const deps = mockDeps({
|
||||
const deps = startableDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return "GARBAGE";
|
||||
@@ -500,7 +641,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 25: start spawns process.execPath, not bare "node"
|
||||
it("start: spawns process.execPath as the Node binary", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
|
||||
|
||||
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
|
||||
@@ -638,3 +779,142 @@ describe("resolveDaemonPath", () => {
|
||||
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("startDaemon env sanitization", () => {
|
||||
it("startDaemon spawns with sanitized env that excludes terminal/GUI vars", async () => {
|
||||
// Pollute process.env temporarily
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const pollutants: Record<string, string> = {
|
||||
GHOSTTY_BIN_DIR: "/bad",
|
||||
TERM_PROGRAM: "ghostty",
|
||||
CMUX_WORKSPACE: "workspace:1",
|
||||
CMUX_PANEL_ID: "panel:7",
|
||||
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
|
||||
CODEX_CI: "1",
|
||||
COMMAND_MODE: "unix2003",
|
||||
__CFBundleIdentifier: "com.test",
|
||||
};
|
||||
for (const [k, v] of Object.entries(pollutants)) {
|
||||
saved[k] = process.env[k];
|
||||
process.env[k] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433, host: "127.0.0.1", db: "/tmp/test.db" }, deps);
|
||||
|
||||
const spawnCall = (deps.spawn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const spawnEnv = spawnCall![2].env as Record<string, string>;
|
||||
|
||||
// Scrubbed vars must NOT be in spawn env
|
||||
expect(spawnEnv["GHOSTTY_BIN_DIR"]).toBeUndefined();
|
||||
expect(spawnEnv["TERM_PROGRAM"]).toBeUndefined();
|
||||
expect(spawnEnv["CMUX_WORKSPACE"]).toBeUndefined();
|
||||
expect(spawnEnv["CODEX_CI"]).toBeUndefined();
|
||||
expect(spawnEnv["COMMAND_MODE"]).toBeUndefined();
|
||||
expect(spawnEnv["__CFBundleIdentifier"]).toBeUndefined();
|
||||
|
||||
// cmux context needed for workspace-bound commands should survive
|
||||
expect(spawnEnv["CMUX_PANEL_ID"]).toBe("panel:7");
|
||||
expect(spawnEnv["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
|
||||
|
||||
// Core vars must be present
|
||||
expect(spawnEnv["HOME"]).toBeDefined();
|
||||
expect(spawnEnv["OPENRIG_PORT"]).toBe("7433");
|
||||
} finally {
|
||||
// Restore process.env
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDaemonEnv", () => {
|
||||
it("strips terminal/GUI vars and preserves core vars", () => {
|
||||
const baseEnv: Record<string, string> = {
|
||||
HOME: "/Users/tester",
|
||||
PATH: "/opt/homebrew/bin:/usr/bin:/bin",
|
||||
USER: "tester",
|
||||
SHELL: "/bin/zsh",
|
||||
COLORTERM: "truecolor",
|
||||
COMMAND_MODE: "unix2003",
|
||||
CODEX_CI: "1",
|
||||
CODEX_THREAD_ID: "thread-123",
|
||||
GHOSTTY_BIN_DIR: "/Applications/Ghostty.app/bin",
|
||||
GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app/resources",
|
||||
GHOSTTY_SHELL_FEATURES: "cursor,title",
|
||||
TERM_PROGRAM: "ghostty",
|
||||
TERM_PROGRAM_VERSION: "1.0",
|
||||
TERMINFO: "/Applications/Ghostty.app/terminfo",
|
||||
XPC_FLAGS: "0x0",
|
||||
XPC_SERVICE_NAME: "0",
|
||||
__CFBundleIdentifier: "com.cmuxterm.app",
|
||||
__CF_USER_TEXT_ENCODING: "0x1F5:0x0:0x0",
|
||||
CMUX_BUNDLE_ID: "com.cmuxterm.app",
|
||||
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
|
||||
CMUX_PANEL_ID: "panel:1",
|
||||
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
||||
CMUX_TAB_ID: "tab:1",
|
||||
CMUX_WORKSPACE: "workspace:1",
|
||||
CMUX_WORKSPACE_ID: "workspace:1",
|
||||
CMUX_SURFACE_ID: "surface:3",
|
||||
LANG: "en_US.UTF-8",
|
||||
};
|
||||
|
||||
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/tmp/test.db" });
|
||||
|
||||
// Core vars preserved
|
||||
expect(result["HOME"]).toBe("/Users/tester");
|
||||
expect(result["PATH"]).toBe("/opt/homebrew/bin:/usr/bin:/bin");
|
||||
expect(result["USER"]).toBe("tester");
|
||||
expect(result["SHELL"]).toBe("/bin/zsh");
|
||||
expect(result["LANG"]).toBe("en_US.UTF-8");
|
||||
|
||||
// OPENRIG_* set from opts
|
||||
expect(result["OPENRIG_PORT"]).toBe("7433");
|
||||
expect(result["OPENRIG_HOST"]).toBe("127.0.0.1");
|
||||
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
|
||||
|
||||
// Scrubbed vars absent
|
||||
expect(result["COLORTERM"]).toBeUndefined();
|
||||
expect(result["COMMAND_MODE"]).toBeUndefined();
|
||||
expect(result["CODEX_CI"]).toBeUndefined();
|
||||
expect(result["CODEX_THREAD_ID"]).toBeUndefined();
|
||||
expect(result["GHOSTTY_BIN_DIR"]).toBeUndefined();
|
||||
expect(result["GHOSTTY_RESOURCES_DIR"]).toBeUndefined();
|
||||
expect(result["GHOSTTY_SHELL_FEATURES"]).toBeUndefined();
|
||||
expect(result["TERM_PROGRAM"]).toBeUndefined();
|
||||
expect(result["TERM_PROGRAM_VERSION"]).toBeUndefined();
|
||||
expect(result["TERMINFO"]).toBeUndefined();
|
||||
expect(result["XPC_FLAGS"]).toBeUndefined();
|
||||
expect(result["XPC_SERVICE_NAME"]).toBeUndefined();
|
||||
expect(result["__CFBundleIdentifier"]).toBeUndefined();
|
||||
expect(result["__CF_USER_TEXT_ENCODING"]).toBeUndefined();
|
||||
expect(result["CMUX_SOCKET_PATH"]).toBeUndefined();
|
||||
expect(result["CMUX_WORKSPACE"]).toBeUndefined();
|
||||
expect(result["CMUX_SURFACE_ID"]).toBeUndefined();
|
||||
|
||||
// cmux context needed for workspace-bound commands is preserved
|
||||
expect(result["CMUX_BUNDLE_ID"]).toBe("com.cmuxterm.app");
|
||||
expect(result["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
|
||||
expect(result["CMUX_PANEL_ID"]).toBe("panel:1");
|
||||
expect(result["CMUX_TAB_ID"]).toBe("tab:1");
|
||||
expect(result["CMUX_WORKSPACE_ID"]).toBe("workspace:1");
|
||||
});
|
||||
|
||||
it("explicit opts override inherited OPENRIG_* from base env", () => {
|
||||
const baseEnv: Record<string, string> = {
|
||||
HOME: "/Users/tester",
|
||||
PATH: "/usr/bin",
|
||||
OPENRIG_PORT: "9999",
|
||||
OPENRIG_DB: "/old/path.db",
|
||||
};
|
||||
|
||||
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/new/path.db" });
|
||||
|
||||
expect(result["OPENRIG_PORT"]).toBe("7433");
|
||||
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import Database from "better-sqlite3";
|
||||
import {
|
||||
DESTROY_CONFIRM_TOKEN,
|
||||
buildBackupPath,
|
||||
buildDestroyPlan,
|
||||
executeDestroy,
|
||||
listManagedTmuxSessionsFromDb,
|
||||
type DestroyDeps,
|
||||
} from "../src/destroy-helpers.js";
|
||||
import { destroyCommand } from "../src/commands/destroy.js";
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function makeDestroyDeps(overrides?: Partial<DestroyDeps>): DestroyDeps {
|
||||
return {
|
||||
stopDaemon: vi.fn(async () => {}),
|
||||
inspectListener: vi.fn(async () => ({ kind: "unreachable" as const })),
|
||||
findListeningPid: vi.fn(() => null),
|
||||
killProcess: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
renamePath: vi.fn(),
|
||||
removePath: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
listManagedTmuxSessions: vi.fn(() => []),
|
||||
killTmuxSession: vi.fn(() => true),
|
||||
sleep: vi.fn(async () => {}),
|
||||
now: vi.fn(() => new Date("2026-04-10T08:10:11Z")),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("destroy helpers", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "openrig-destroy-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("buildBackupPath appends timestamp and resolves collisions", () => {
|
||||
const target = join(tmpDir, ".openrig");
|
||||
const first = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
|
||||
expect(basename(first)).toMatch(/^\.openrig\.backup-20260410-\d{6}$/);
|
||||
|
||||
mkdirSync(first, { recursive: true });
|
||||
const second = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
|
||||
expect(second).toBe(`${first}-2`);
|
||||
});
|
||||
|
||||
it("listManagedTmuxSessionsFromDb returns distinct tmux-backed sessions", () => {
|
||||
const dbPath = join(tmpDir, "openrig.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE nodes (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
attachment_type TEXT NOT NULL DEFAULT 'tmux',
|
||||
external_session_name TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n3");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
|
||||
.run("b1", "n1", "orch1-lead@demo", "tmux");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
|
||||
.run("b2", "n2", "orch1-lead@demo", "tmux");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
|
||||
.run("s3", "n3", "dev1-impl@demo");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, attachment_type, external_session_name) VALUES (?, ?, ?, ?)")
|
||||
.run("b3", "n3", "external_cli", "ext-cli");
|
||||
db.close();
|
||||
|
||||
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["orch1-lead@demo"]);
|
||||
});
|
||||
|
||||
it("listManagedTmuxSessionsFromDb prefers the current session for a node over historical names", () => {
|
||||
const dbPath = join(tmpDir, "openrig-current.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE nodes (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
attachment_type TEXT NOT NULL DEFAULT 'tmux',
|
||||
external_session_name TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run("s1", "n1", "old-name@demo", "2026-04-10T08:00:00Z");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run("s2", "n1", "current-name@demo", "2026-04-10T08:05:00Z");
|
||||
db.close();
|
||||
|
||||
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["current-name@demo"]);
|
||||
});
|
||||
|
||||
it("buildDestroyPlan includes external db/transcript targets outside state root", () => {
|
||||
const stateRoot = join(tmpDir, ".openrig");
|
||||
const externalDb = join(tmpDir, "custom", "openrig.sqlite");
|
||||
const externalTx = join(tmpDir, "custom", "transcripts");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
mkdirSync(join(tmpDir, "custom"), { recursive: true });
|
||||
writeFileSync(externalDb, "");
|
||||
mkdirSync(externalTx, { recursive: true });
|
||||
|
||||
const deps = makeDestroyDeps({
|
||||
exists: (path: string) => existsSync(path),
|
||||
listManagedTmuxSessions: () => [],
|
||||
});
|
||||
|
||||
const plan = buildDestroyPlan("state", true, {
|
||||
stateRoot,
|
||||
dbPath: externalDb,
|
||||
transcriptsPath: externalTx,
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
}, deps);
|
||||
|
||||
expect(plan.targets.map((target) => target.kind)).toEqual(["state_root", "db_file", "transcripts_dir"]);
|
||||
expect(plan.warnings.length).toBe(2);
|
||||
});
|
||||
|
||||
it("executeDestroy stops daemon, kills managed tmux sessions, backs up state root, and recreates it", async () => {
|
||||
const stateRoot = join(tmpDir, ".openrig");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
writeFileSync(join(stateRoot, "daemon.json"), "{}");
|
||||
const dbPath = join(stateRoot, "openrig.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE nodes (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
attachment_type TEXT NOT NULL DEFAULT 'tmux',
|
||||
external_session_name TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
|
||||
.run("b1", "n1", "orch1-lead@demo", "tmux");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
|
||||
.run("s2", "n2", "dev1-impl@demo");
|
||||
db.close();
|
||||
|
||||
const deps = makeDestroyDeps({
|
||||
exists: (path: string) => existsSync(path),
|
||||
renamePath: (from: string, to: string) => renameSync(from, to),
|
||||
mkdirp: (path: string) => mkdirSync(path, { recursive: true }),
|
||||
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
|
||||
killTmuxSession: (sessionName: string) => sessionName !== "dev1-impl@demo",
|
||||
inspectListener: vi.fn()
|
||||
.mockResolvedValueOnce({ kind: "openrig" })
|
||||
.mockResolvedValueOnce({ kind: "unreachable" })
|
||||
.mockResolvedValueOnce({ kind: "unreachable" }),
|
||||
findListeningPid: () => 1234,
|
||||
});
|
||||
|
||||
const plan = buildDestroyPlan("all", true, {
|
||||
stateRoot,
|
||||
dbPath,
|
||||
transcriptsPath: join(stateRoot, "transcripts"),
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
}, deps);
|
||||
|
||||
const result = await executeDestroy(plan, deps);
|
||||
expect(result.daemonStopped).toBe(true);
|
||||
expect(result.portCleared).toBe(true);
|
||||
expect(result.stateRecreated).toBe(true);
|
||||
expect(result.tmuxKilled).toBe(1);
|
||||
expect(result.tmuxMissing).toBe(1);
|
||||
expect(result.backupPaths).toHaveLength(1);
|
||||
expect(existsSync(stateRoot)).toBe(true);
|
||||
expect(deps.killProcess).toHaveBeenCalledWith(1234);
|
||||
});
|
||||
|
||||
it("executeDestroy leaves state untouched when a local listener is still live but cannot be safely cleared", async () => {
|
||||
const stateRoot = join(tmpDir, ".openrig-live");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
const removePath = vi.fn();
|
||||
const mkdirp = vi.fn();
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => true,
|
||||
removePath,
|
||||
mkdirp,
|
||||
inspectListener: vi.fn(async () => ({ kind: "openrig" })),
|
||||
findListeningPid: () => null,
|
||||
});
|
||||
|
||||
const result = await executeDestroy({
|
||||
scope: "state",
|
||||
backup: false,
|
||||
stateRoot,
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
managedTmuxSessions: [],
|
||||
targets: [{ path: stateRoot, kind: "state_root" }],
|
||||
warnings: [],
|
||||
}, deps);
|
||||
|
||||
expect(result.portCleared).toBe(false);
|
||||
expect(result.stateRecreated).toBe(false);
|
||||
expect(removePath).not.toHaveBeenCalled();
|
||||
expect(mkdirp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executeDestroy does not kill a live non-OpenRig HTTP listener", async () => {
|
||||
const killProcess = vi.fn();
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => true,
|
||||
removePath: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
inspectListener: vi.fn(async () => ({ kind: "other_http", detail: "HTTP 200" })),
|
||||
findListeningPid: () => 7777,
|
||||
killProcess,
|
||||
});
|
||||
|
||||
const result = await executeDestroy({
|
||||
scope: "state",
|
||||
backup: false,
|
||||
stateRoot: join(tmpDir, ".openrig-http"),
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
managedTmuxSessions: [],
|
||||
targets: [{ path: join(tmpDir, ".openrig-http"), kind: "state_root" }],
|
||||
warnings: [],
|
||||
}, deps);
|
||||
|
||||
expect(result.portCleared).toBe(false);
|
||||
expect(killProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executeDestroy does not enumerate or kill tmux sessions for --state", async () => {
|
||||
const stateRoot = join(tmpDir, ".openrig-state-only");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
const listManagedTmuxSessions = vi.fn(() => ["orchestrator@demo"]);
|
||||
const killTmuxSession = vi.fn(() => true);
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => true,
|
||||
removePath: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
listManagedTmuxSessions,
|
||||
killTmuxSession,
|
||||
});
|
||||
|
||||
const plan = buildDestroyPlan("state", false, {
|
||||
stateRoot,
|
||||
dbPath: join(stateRoot, "openrig.sqlite"),
|
||||
transcriptsPath: join(stateRoot, "transcripts"),
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
}, deps);
|
||||
await executeDestroy(plan, deps);
|
||||
|
||||
expect(listManagedTmuxSessions).not.toHaveBeenCalled();
|
||||
expect(killTmuxSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executeDestroy does not report remote daemon targets as cleared", async () => {
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => false,
|
||||
mkdirp: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await executeDestroy({
|
||||
scope: "state",
|
||||
backup: false,
|
||||
stateRoot: join(tmpDir, ".openrig-remote"),
|
||||
daemonHost: "10.0.0.5",
|
||||
daemonPort: 7433,
|
||||
managedTmuxSessions: [],
|
||||
targets: [],
|
||||
warnings: [],
|
||||
}, deps);
|
||||
|
||||
expect(result.daemonStopped).toBe(false);
|
||||
expect(result.portCleared).toBe(false);
|
||||
expect(result.stateRecreated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy command", () => {
|
||||
it("requires exactly one destroy scope", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps: makeDestroyDeps(),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("Specify exactly one destroy scope");
|
||||
});
|
||||
|
||||
it("requires explicit confirmation token", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps: makeDestroyDeps(),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", "wrong"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
|
||||
});
|
||||
|
||||
it("requires --yes", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps: makeDestroyDeps(),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("Destroy requires --yes");
|
||||
});
|
||||
|
||||
it("runs destroy with backup mode and prints plan/result", async () => {
|
||||
const destroyDeps = makeDestroyDeps({
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps,
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--backup", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("DESTROY PLAN");
|
||||
expect(output).toContain("scope: state");
|
||||
expect(output).toContain("DESTROY RESULT");
|
||||
expect(output).toContain("fresh state root:");
|
||||
});
|
||||
|
||||
it("falls back to compatibility defaults when config resolution is malformed", async () => {
|
||||
const destroyDeps = makeDestroyDeps({
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => { throw new Error("bad config json"); } },
|
||||
destroyDeps,
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("DESTROY PLAN");
|
||||
expect(logs.join("\n")).toContain("bad config json");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { doctorCommand, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
|
||||
|
||||
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
|
||||
|
||||
const defaultConfig = {
|
||||
daemon: { port: 7433, host: "127.0.0.1" },
|
||||
@@ -12,9 +15,16 @@ function makeDeps(overrides?: Partial<DoctorDeps>): DoctorDeps {
|
||||
return {
|
||||
exists: () => true,
|
||||
baseDir: "/install/cli/dist",
|
||||
exec: () => "tmux 3.4\n",
|
||||
readFile: () => null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
checkPort: async () => true,
|
||||
configStore: { resolve: () => defaultConfig },
|
||||
platform: "darwin",
|
||||
mkdirp: () => {},
|
||||
checkWritable: () => {},
|
||||
...overrides,
|
||||
@@ -68,20 +78,309 @@ describe("runDoctorChecks", () => {
|
||||
});
|
||||
|
||||
it("tmux available -> pass", () => {
|
||||
const deps = makeDeps({ exec: () => "tmux 3.4" });
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") return "";
|
||||
if (cmd === "tmux show-options -gqv mouse") return "on\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const tmuxCheck = checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("tmux installed but default control socket unhealthy -> fail with attention guidance", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const tmuxCheck = checks.find((c) => c.name === "tmux");
|
||||
|
||||
expect(tmuxCheck?.status).toBe("fail");
|
||||
expect(tmuxCheck?.message).toContain("control socket");
|
||||
expect(tmuxCheck?.reason).toContain("server exited unexpectedly");
|
||||
expect(tmuxCheck?.fix).toContain("restart the default tmux server");
|
||||
});
|
||||
|
||||
it("tmux mouse enabled on macOS -> pass", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux show-options -gqv mouse") return "on\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
|
||||
expect(mouseCheck?.status).toBe("pass");
|
||||
expect(mouseCheck?.message).toContain("enabled");
|
||||
});
|
||||
|
||||
it("tmux mouse disabled on macOS -> warn with exact fix", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux show-options -gqv mouse") return "off\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
|
||||
expect(mouseCheck?.status).toBe("warn");
|
||||
expect(mouseCheck?.message).toContain("disabled");
|
||||
expect(mouseCheck?.fix).toContain("tmux set -g mouse on");
|
||||
expect(mouseCheck?.fix).toContain("~/.tmux.conf");
|
||||
expect(mouseCheck?.fix).toContain("tmux source-file ~/.tmux.conf");
|
||||
});
|
||||
|
||||
it("tmux missing -> fail with guidance", () => {
|
||||
const deps = makeDeps({ exec: () => { throw new Error("not found"); } });
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") throw new Error("not found");
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const tmuxCheck = checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck?.status).toBe("fail");
|
||||
expect(tmuxCheck?.fix).toContain("brew");
|
||||
});
|
||||
|
||||
it("tmux mouse check is omitted on non-macOS hosts", () => {
|
||||
const deps = makeDeps({
|
||||
platform: "linux",
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
expect(checks.find((c) => c.name === "tmux_mouse")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cmux_shell pass when shell capabilities work", () => {
|
||||
const deps = makeDeps();
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("cmux_shell warn when shell cmux installed but control unavailable", () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") {
|
||||
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
|
||||
}
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("warn");
|
||||
expect(cmuxShell?.message).toContain("control unavailable");
|
||||
expect(cmuxShell?.fix).toContain("cmuxOnly");
|
||||
expect(cmuxShell?.fix).toContain("socketControlMode");
|
||||
});
|
||||
|
||||
it("cmux_shell warn when cmux missing", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
throw new Error("command not found: cmux");
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("warn");
|
||||
expect(cmuxShell?.message).toContain("not found");
|
||||
});
|
||||
|
||||
it("shell cmux pass + daemon cmux unavailable -> cmux_daemon warn with mismatch guidance", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false, // port in use = daemon running
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxShell = allChecks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("pass");
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.message).toContain("daemon cannot control");
|
||||
expect(cmuxDaemon?.fix).toContain("rig daemon start");
|
||||
});
|
||||
|
||||
it("shell cmux pass + restrictive macOS socket control + daemon cmux unavailable -> cmux_daemon points at socket control mode", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("socketControlMode");
|
||||
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
|
||||
expect(cmuxDaemon?.fix).toContain("rig setup");
|
||||
expect(cmuxDaemon?.fix).toContain("automation");
|
||||
});
|
||||
|
||||
it("shell cmux pass + implicit default cmuxOnly + daemon cmux unavailable -> cmux_daemon points at effective default, not a fake file value", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("default");
|
||||
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
|
||||
expect(cmuxDaemon?.reason).not.toContain("is 'cmuxOnly' in");
|
||||
});
|
||||
|
||||
it("shell cmux pass + unreadable cmux settings -> cmux_daemon warns about parse failure", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n invalid\n" : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("unreadable");
|
||||
expect(cmuxDaemon?.fix).toContain("~/.config/cmux/settings.json");
|
||||
});
|
||||
|
||||
it("shell cmux pass + compatible password mode + daemon cmux unavailable -> falls back to generic daemon warning", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("inherited a terminal/session environment");
|
||||
expect(cmuxDaemon?.reason).not.toContain("socketControlMode");
|
||||
});
|
||||
|
||||
it("daemon not running -> cmux_daemon skipped and does not make doctor unhealthy", async () => {
|
||||
const deps = makeDeps({
|
||||
checkPort: async () => true,
|
||||
fetch: async () => { throw new Error("ECONNREFUSED"); },
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("skipped");
|
||||
expect(cmuxDaemon?.message).toContain("not reachable");
|
||||
|
||||
const healthy = allChecks.every((c) => c.status !== "fail");
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("daemon cmux available -> cmux_daemon pass", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false, // port in use = daemon running
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("shell cmux missing -> no cmux_daemon check (no misleading signal)", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
throw new Error("command not found: cmux");
|
||||
},
|
||||
checkPort: async () => false, // daemon running
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Node version check passes on current Node", () => {
|
||||
const deps = makeDeps();
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
@@ -174,6 +473,31 @@ describe("rig doctor", () => {
|
||||
expect(parsed.checks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("--json stays healthy when cmux is only a warning", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") {
|
||||
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
|
||||
}
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(deps));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "doctor", "--json"]),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
const cmuxShellCheck = parsed.checks.find((check: { name: string }) => check.name === "cmux_shell");
|
||||
expect(parsed.healthy).toBe(true);
|
||||
expect(cmuxShellCheck.status).toBe("warn");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--json exits non-zero when writable state paths fail", async () => {
|
||||
const deps = makeDeps({
|
||||
checkWritable: () => {
|
||||
@@ -194,6 +518,49 @@ describe("rig doctor", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("--json surfaces cmux_daemon pass when daemon cmux is available", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(deps));
|
||||
|
||||
const { logs } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "doctor", "--json"]),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("--json surfaces cmux_daemon warn when shell cmux works but daemon cannot control", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(deps));
|
||||
|
||||
const { logs } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "doctor", "--json"]),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon.status).toBe("warn");
|
||||
expect(parsed.healthy).toBe(true); // warn does not make doctor unhealthy
|
||||
});
|
||||
|
||||
it("wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { envCommand } from "../src/commands/env.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
describe("rig env", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(rigSummary));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/env/logs")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, output: "vault | 2026-04-09 12:00:00 Starting vault server..." }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/env/down") && req.method === "POST") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/env")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
hasServices: true,
|
||||
kind: "compose",
|
||||
projectName: "my-rig",
|
||||
receipt: {
|
||||
kind: "compose",
|
||||
services: [{ name: "vault", status: "running", health: "healthy" }],
|
||||
waitFor: [],
|
||||
capturedAt: "2026-04-09T00:00:00Z",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, resolve); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
function runningDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-04-09T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(envCommand(runningDeps()));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("env help explains managed-app usage", () => {
|
||||
const logs: string[] = [];
|
||||
const cmd = makeCmd().commands.find((c) => c.name() === "env")!;
|
||||
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
|
||||
cmd.outputHelp();
|
||||
const help = logs.join("");
|
||||
expect(help).toContain("Inspect and control rig environment services");
|
||||
expect(help).toContain("service-backed rigs and managed");
|
||||
expect(help).toContain("rig env status secrets-manager");
|
||||
expect(help).toContain("rig env logs secrets-manager vault");
|
||||
});
|
||||
|
||||
it("env status prints service status", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig"]);
|
||||
});
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("vault");
|
||||
expect(output).toContain("running");
|
||||
});
|
||||
|
||||
it("env status --json returns machine-readable output", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig", "--json"]);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.hasServices).toBe(true);
|
||||
expect(parsed.receipt.services).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("env logs returns service logs", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "logs", "my-rig"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Starting vault server");
|
||||
});
|
||||
|
||||
it("env down stops services", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "down", "my-rig"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Services stopped");
|
||||
});
|
||||
|
||||
it("env is wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
const envCmd = program.commands.find((c) => c.name() === "env");
|
||||
expect(envCmd).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -154,8 +154,13 @@ describe("rig expand", () => {
|
||||
expect(parsed.nodes).toBeDefined();
|
||||
});
|
||||
|
||||
// T3a: Partial prints failed nodes + honest relaunch guidance
|
||||
it("partial result prints failed nodes with relaunch guidance", async () => {
|
||||
// T3a: Partial prints failed nodes + honest recovery guidance.
|
||||
//
|
||||
// Recovery shape mandated by the topology-mutation-add-seat-proof-pass
|
||||
// slice: pod-aware /launch returns pod_aware_launch_unsupported for any
|
||||
// node `expand` creates, so the per-node `rig launch <rigId> <logicalId>`
|
||||
// form is doomed. The honest recovery path is shrink → fix → re-expand.
|
||||
it("partial result prints failed nodes with shrink+re-expand recovery guidance", async () => {
|
||||
writeFileSync(fragmentPath, `id: partial-pod\nlabel: Dev\nmembers:\n - id: impl\n runtime: claude-code\n - id: qa\n runtime: codex\nedges: []\n`);
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
@@ -165,7 +170,10 @@ describe("rig expand", () => {
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("FAIL");
|
||||
expect(output).toContain("dev.qa");
|
||||
expect(output).toContain("rig launch rig-123 dev.qa");
|
||||
expect(output).toContain("Failed nodes cannot be relaunched directly");
|
||||
expect(output).toContain("rig shrink rig-123 dev"); // dev is podNamespace from PARTIAL_RESPONSE
|
||||
expect(output).toContain("Re-run: rig expand rig-123");
|
||||
expect(output).toContain("Failed targets: dev.qa");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -181,8 +189,13 @@ describe("rig expand", () => {
|
||||
expect(output).not.toContain("/launch");
|
||||
});
|
||||
|
||||
// T3b: Human output never suggests 'rig up' or 'rerun expand'
|
||||
it("partial output never suggests rig up or rerun expand", async () => {
|
||||
// T3b: Human output NEVER advertises the doomed per-node `rig launch
|
||||
// <rigId> <logicalId>` form for pod-aware expand failures. The daemon
|
||||
// /launch route returns pod_aware_launch_unsupported (HTTP 409) for every
|
||||
// node expand creates because expand always materializes via the pod-aware
|
||||
// seam. Advertising that retry is identity-honesty failure: telling
|
||||
// operators to run a command we know will refuse them.
|
||||
it("partial output does NOT advertise the doomed `rig launch <rigId> <logicalId>` form", async () => {
|
||||
writeFileSync(fragmentPath, `id: partial-pod\nlabel: Dev\nmembers:\n - id: impl\n runtime: claude-code\n - id: qa\n runtime: codex\nedges: []\n`);
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
@@ -190,9 +203,10 @@ describe("rig expand", () => {
|
||||
});
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).not.toContain("rig up");
|
||||
expect(output).not.toMatch(/rerun.*expand/i);
|
||||
expect(output).not.toMatch(/rig expand/i);
|
||||
// The exact doomed form for the failed node MUST NOT appear.
|
||||
expect(output).not.toContain("rig launch rig-123 dev.qa");
|
||||
// Defensive: no `rig launch <rigId> <bare-logical-id>` style line at all.
|
||||
expect(output).not.toMatch(/rig launch rig-123 \w/);
|
||||
});
|
||||
|
||||
// T4: API error -> exit 1
|
||||
|
||||
@@ -53,7 +53,10 @@ function runningLifecycleDeps(port: number): LifecycleDeps {
|
||||
}
|
||||
|
||||
function stoppedLifecycleDeps(): LifecycleDeps {
|
||||
return mockLifecycleDeps({ exists: vi.fn(() => false) });
|
||||
return mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
}
|
||||
|
||||
function unhealthyLifecycleDeps(): LifecycleDeps {
|
||||
@@ -434,4 +437,40 @@ describe("rig export + import", () => {
|
||||
expect(output).toContain("Attach:");
|
||||
expect(output).toContain("tmux attach -t orch-lead@imported-rig");
|
||||
});
|
||||
|
||||
it("import --instantiate uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const postText = vi.fn(async (
|
||||
_path: string,
|
||||
_text: string,
|
||||
_contentType?: string,
|
||||
_extraHeaders?: Record<string, string>,
|
||||
options?: { timeoutMs?: number },
|
||||
) => {
|
||||
timeoutMs = options?.timeoutMs;
|
||||
return {
|
||||
status: 201,
|
||||
data: {
|
||||
rigId: "rig-new",
|
||||
specName: "imported-rig",
|
||||
specVersion: "0.1.0",
|
||||
nodes: [{ logicalId: "orchestrator", status: "launched" }],
|
||||
attachCommand: "tmux attach -t orch-lead@imported-rig",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const deps: ImportDeps = {
|
||||
lifecycleDeps: runningLifecycleDeps(port),
|
||||
clientFactory: () => ({ postText } as unknown as DaemonClient),
|
||||
readFile: vi.fn(() => 'version: "0.2"\nname: test\npods:\n - id: dev\n label: Dev\n members:\n - id: impl\n agent_ref: "local:agents/impl"\n profile: default\n runtime: claude-code\n cwd: .\n edges: []\nedges: []'),
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(importCommand(deps));
|
||||
|
||||
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
|
||||
|
||||
expect(timeoutMs).toBe(120_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { heartbeatCommand, analyzeHeartbeat } from "../src/commands/heartbeat.js";
|
||||
|
||||
const NOW = new Date("2026-04-24T12:00:00Z");
|
||||
|
||||
function writeQueue(root: string, rig: string, pod: string, member: string, body: string): string {
|
||||
const dir = join(root, "rigs", rig, "state", pod);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, `${member}.queue.md`);
|
||||
writeFileSync(path, body, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
function queue(entries: string[]): string {
|
||||
return [
|
||||
"---",
|
||||
"doc: test queue",
|
||||
"---",
|
||||
"",
|
||||
"# queue",
|
||||
"",
|
||||
...entries,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function entry(input: {
|
||||
id: string;
|
||||
state: string;
|
||||
tsCreated?: string;
|
||||
tsUpdated?: string;
|
||||
blockedOn?: string;
|
||||
title?: string;
|
||||
body: string;
|
||||
}): string {
|
||||
return [
|
||||
"---",
|
||||
`id: ${input.id}`,
|
||||
`ts-created: ${input.tsCreated ?? "2026-04-24T08:00:00Z"}`,
|
||||
`ts-updated: ${input.tsUpdated ?? input.tsCreated ?? "2026-04-24T08:00:00Z"}`,
|
||||
`state: ${input.state}`,
|
||||
`blocked-on: ${input.blockedOn ?? "null"}`,
|
||||
"handed-off-to: null",
|
||||
"---",
|
||||
"",
|
||||
`### ${input.title ?? input.id}`,
|
||||
"",
|
||||
input.body,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errors: string[]; exitCode: number | undefined }> {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
return { logs, errors, exitCode };
|
||||
}
|
||||
|
||||
describe("rig heartbeat", () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "openrig-heartbeat-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("classifies checked-out, proven-active, stalled, unproven, blocked, parked, and done states", () => {
|
||||
writeQueue(root, "alpha", "dev", "impl", queue([
|
||||
entry({
|
||||
id: "checked",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T11:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - in-progress (accepted)",
|
||||
}),
|
||||
entry({
|
||||
id: "unproven",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T08:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - in-progress (accepted)",
|
||||
}),
|
||||
entry({
|
||||
id: "proven",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T08:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - in-progress\n- 2026-04-24T11:30:00Z - in-progress; proof: wrote `openrig-work/artifact.md`",
|
||||
}),
|
||||
entry({
|
||||
id: "stalled",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T06:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T06:00:00Z - in-progress\n- 2026-04-24T09:30:00Z - in-progress; proof: changed `packages/cli/src/commands/heartbeat.ts`",
|
||||
}),
|
||||
entry({
|
||||
id: "not-proof",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T07:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T07:00:00Z - in-progress\n- 2026-04-24T11:30:00Z - in-progress; proof: read the spec carefully",
|
||||
}),
|
||||
entry({
|
||||
id: "blocked",
|
||||
state: "blocked",
|
||||
blockedOn: "missing credentials",
|
||||
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - blocked (missing credentials)",
|
||||
}),
|
||||
entry({
|
||||
id: "parked",
|
||||
state: "deferred",
|
||||
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - deferred (waiting for review)",
|
||||
}),
|
||||
entry({
|
||||
id: "done",
|
||||
state: "done",
|
||||
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - done (landed artifact at `out.md`)",
|
||||
}),
|
||||
]));
|
||||
|
||||
const result = analyzeHeartbeat({
|
||||
sharedDocsRoot: root,
|
||||
rig: "alpha",
|
||||
now: NOW,
|
||||
includeDone: true,
|
||||
});
|
||||
const byId = new Map(result.items.map((item) => [item.id, item]));
|
||||
|
||||
expect(byId.get("checked")?.executionState).toBe("checked-out");
|
||||
expect(byId.get("unproven")?.executionState).toBe("unproven");
|
||||
expect(byId.get("proven")?.executionState).toBe("proven-active");
|
||||
expect(byId.get("stalled")?.executionState).toBe("stalled");
|
||||
expect(byId.get("not-proof")?.executionState).toBe("unproven");
|
||||
expect(byId.get("blocked")?.executionState).toBe("blocked");
|
||||
expect(byId.get("blocked")?.blockedOn).toBe("missing credentials");
|
||||
expect(byId.get("parked")?.executionState).toBe("parked");
|
||||
expect(byId.get("done")?.executionState).toBe("done");
|
||||
expect(result.summary).toMatchObject({
|
||||
total: 8,
|
||||
checkedOut: 1,
|
||||
provenActive: 1,
|
||||
stalled: 1,
|
||||
unproven: 2,
|
||||
blocked: 1,
|
||||
parked: 1,
|
||||
done: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("JSON output is stable and excludes done items by default", async () => {
|
||||
writeQueue(root, "alpha", "dev", "impl", queue([
|
||||
entry({
|
||||
id: "active",
|
||||
state: "in-progress",
|
||||
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - in-progress",
|
||||
}),
|
||||
entry({
|
||||
id: "done",
|
||||
state: "done",
|
||||
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - done",
|
||||
}),
|
||||
]));
|
||||
|
||||
const cmd = heartbeatCommand({ sharedDocsRoot: root, now: () => NOW });
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "alpha", "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.windows).toEqual({ firstProofSeconds: 7200, heartbeatSeconds: 7200 });
|
||||
expect(parsed.summary.total).toBe(1);
|
||||
expect(parsed.summary.done).toBe(0);
|
||||
expect(parsed.items).toHaveLength(1);
|
||||
expect(parsed.items[0]).toMatchObject({
|
||||
id: "active",
|
||||
rig: "alpha",
|
||||
owner: "dev.impl",
|
||||
session: "dev-impl@alpha",
|
||||
executionState: "checked-out",
|
||||
});
|
||||
});
|
||||
|
||||
it("--include-done includes terminal done items", async () => {
|
||||
writeQueue(root, "alpha", "dev", "impl", queue([
|
||||
entry({
|
||||
id: "done",
|
||||
state: "done",
|
||||
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - done",
|
||||
}),
|
||||
]));
|
||||
|
||||
const cmd = heartbeatCommand({ sharedDocsRoot: root, now: () => NOW });
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "alpha", "--json", "--include-done"]);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.summary.done).toBe(1);
|
||||
expect(parsed.items[0].executionState).toBe("done");
|
||||
});
|
||||
|
||||
it("does not label pending or unknown queue states as checked out", () => {
|
||||
writeQueue(root, "alpha", "dev", "impl", queue([
|
||||
entry({
|
||||
id: "pending",
|
||||
state: "pending",
|
||||
tsCreated: "2026-04-24T08:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - pending",
|
||||
}),
|
||||
entry({
|
||||
id: "unknown",
|
||||
state: "triage",
|
||||
tsCreated: "2026-04-24T08:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - triage",
|
||||
}),
|
||||
entry({
|
||||
id: "checked",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T11:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - in-progress",
|
||||
}),
|
||||
]));
|
||||
|
||||
const result = analyzeHeartbeat({
|
||||
sharedDocsRoot: root,
|
||||
rig: "alpha",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(result.items.map((item) => item.id)).toEqual(["checked"]);
|
||||
expect(result.items[0]?.executionState).toBe("checked-out");
|
||||
});
|
||||
|
||||
it("uses checkout transition then ts-created, not ts-updated churn, for first-proof age", () => {
|
||||
writeQueue(root, "alpha", "dev", "impl", queue([
|
||||
entry({
|
||||
id: "metadata-churn",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T08:00:00Z",
|
||||
tsUpdated: "2026-04-24T11:50:00Z",
|
||||
body: "No in-progress transition was recorded.",
|
||||
}),
|
||||
]));
|
||||
|
||||
const result = analyzeHeartbeat({
|
||||
sharedDocsRoot: root,
|
||||
rig: "alpha",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(result.items[0]?.id).toBe("metadata-churn");
|
||||
expect(result.items[0]?.checkoutAt).toBe("2026-04-24T08:00:00Z");
|
||||
expect(result.items[0]?.executionState).toBe("unproven");
|
||||
});
|
||||
|
||||
it("--nudge sends informational messages only to stalled and unproven owners", async () => {
|
||||
writeQueue(root, "alpha", "dev", "impl", queue([
|
||||
entry({
|
||||
id: "unproven",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T08:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - in-progress",
|
||||
}),
|
||||
entry({
|
||||
id: "stalled",
|
||||
state: "in-progress",
|
||||
tsCreated: "2026-04-24T06:00:00Z",
|
||||
body: "**State transitions:**\n- 2026-04-24T06:00:00Z - in-progress\n- 2026-04-24T09:00:00Z - in-progress; proof: wrote `artifact.md`",
|
||||
}),
|
||||
entry({
|
||||
id: "blocked",
|
||||
state: "blocked",
|
||||
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - blocked",
|
||||
}),
|
||||
]));
|
||||
const sends: Array<{ session: string; text: string }> = [];
|
||||
const cmd = heartbeatCommand({
|
||||
sharedDocsRoot: root,
|
||||
now: () => NOW,
|
||||
send: async (session, text) => {
|
||||
sends.push({ session, text });
|
||||
return { ok: true, message: "sent" };
|
||||
},
|
||||
});
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "alpha", "--json", "--nudge"]);
|
||||
});
|
||||
|
||||
expect(sends.map((send) => send.session)).toEqual(["dev-impl@alpha", "dev-impl@alpha"]);
|
||||
expect(sends.map((send) => send.text).join("\n")).toContain("add a task-specific proof note");
|
||||
expect(sends.map((send) => send.text).join("\n")).toContain("If blocked, transition to blocked");
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.nudgeResults).toHaveLength(2);
|
||||
expect(parsed.nudgeResults.map((result: { id: string }) => result.id).sort()).toEqual(["stalled", "unproven"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
loadHostRegistry,
|
||||
resolveHost,
|
||||
validateHostRegistry,
|
||||
defaultHostRegistryPath,
|
||||
} from "../src/host-registry.js";
|
||||
|
||||
function withTempFile(name: string, contents: string, fn: (path: string) => void) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "openrig-host-registry-"));
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, contents, "utf-8");
|
||||
try {
|
||||
fn(path);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("host registry — defaultHostRegistryPath", () => {
|
||||
it("returns ~/.openrig/hosts.yaml under the canonical OpenRig home", () => {
|
||||
const path = defaultHostRegistryPath();
|
||||
expect(path.endsWith("/hosts.yaml")).toBe(true);
|
||||
expect(path.includes(".openrig")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host registry — loadHostRegistry", () => {
|
||||
it("loads a valid single-host registry", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- id: vm-claude-test
|
||||
transport: ssh
|
||||
target: vm-claude-test.local
|
||||
user: wrandom
|
||||
notes: "Tart VM"
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.registry.hosts).toHaveLength(1);
|
||||
expect(r.registry.hosts[0]).toEqual({
|
||||
id: "vm-claude-test",
|
||||
transport: "ssh",
|
||||
target: "vm-claude-test.local",
|
||||
user: "wrandom",
|
||||
notes: "Tart VM",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("loads a valid multi-host registry with optional fields omitted", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- id: vm-a
|
||||
transport: ssh
|
||||
target: vm-a.local
|
||||
- id: laptop-b
|
||||
transport: ssh
|
||||
target: laptop-b.tail-scale-net
|
||||
user: wrandom
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.registry.hosts.map((h) => h.id)).toEqual(["vm-a", "laptop-b"]);
|
||||
expect(r.registry.hosts[0]?.user).toBeUndefined();
|
||||
expect(r.registry.hosts[1]?.user).toBe("wrandom");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clear error when the registry file is missing", () => {
|
||||
const r = loadHostRegistry("/tmp/openrig-non-existent-hosts.yaml");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("host registry not found");
|
||||
expect(r.error).toContain("transport: ssh");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-array hosts field", () => {
|
||||
withTempFile("hosts.yaml", `hosts: "not-an-array"\n`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("'hosts' must be an array");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects entry missing required id", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- transport: ssh
|
||||
target: x.local
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("id: required non-empty string");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects entry missing required target", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- id: vm-x
|
||||
transport: ssh
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("target: required non-empty string");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-ssh transport with v0-scope message", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- id: vm-x
|
||||
transport: tailscale
|
||||
target: x.local
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("transport: v0 supports 'ssh' only");
|
||||
expect(r.error).toContain('"tailscale"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate host ids", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- id: vm-x
|
||||
transport: ssh
|
||||
target: x.local
|
||||
- id: vm-x
|
||||
transport: ssh
|
||||
target: y.local
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("duplicate host id 'vm-x'");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty user field", () => {
|
||||
withTempFile("hosts.yaml", `
|
||||
hosts:
|
||||
- id: vm-x
|
||||
transport: ssh
|
||||
target: x.local
|
||||
user: ""
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("user: optional, but if present must be a non-empty string");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed YAML with a clear error", () => {
|
||||
withTempFile("hosts.yaml", `hosts:\n - id: vm-x\n transport: ssh\n target: x.local\nnot-valid: [`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("failed to parse host registry YAML");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects top-level non-object YAML", () => {
|
||||
const result = validateHostRegistry("just-a-string", "/x");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("must be a YAML object with a 'hosts' array");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host registry — resolveHost", () => {
|
||||
const registry = {
|
||||
hosts: [
|
||||
{ id: "vm-a", transport: "ssh" as const, target: "vm-a.local" },
|
||||
{ id: "vm-b", transport: "ssh" as const, target: "vm-b.local", user: "ops" },
|
||||
],
|
||||
};
|
||||
|
||||
it("resolves a known id", () => {
|
||||
const r = resolveHost(registry, "vm-b");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.host.target).toBe("vm-b.local");
|
||||
});
|
||||
|
||||
it("rejects an unknown id with the supported-list hint", () => {
|
||||
const r = resolveHost(registry, "vm-unknown");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("unknown host id 'vm-unknown'");
|
||||
expect(r.error).toContain("vm-a");
|
||||
expect(r.error).toContain("vm-b");
|
||||
}
|
||||
});
|
||||
|
||||
it("indicates an empty registry honestly", () => {
|
||||
const r = resolveHost({ hosts: [] }, "vm-x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("registry is empty");
|
||||
});
|
||||
});
|
||||
@@ -20,4 +20,14 @@ describe("CLI entrypoint direct-run detection", () => {
|
||||
const program = createProgram();
|
||||
expect(program.version()).toBe(CLI_VERSION);
|
||||
});
|
||||
|
||||
it("registers the native heartbeat command", () => {
|
||||
const program = createProgram();
|
||||
expect(program.commands.some((cmd) => cmd.name() === "heartbeat")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the native seat command namespace", () => {
|
||||
const program = createProgram();
|
||||
expect(program.commands.some((cmd) => cmd.name() === "seat")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,16 @@ describe("Lifecycle CLI commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/rigs/rig-1/nodes/dev.pod/launch") {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
ok: false,
|
||||
code: "pod_aware_launch_unsupported",
|
||||
error: "Pod-aware node launch via this route bypasses startup orchestration. Use rig up, rig import --instantiate, or rig restore instead.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/sessions/missing/unclaim") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "Claimed session 'missing' not found." }));
|
||||
@@ -269,6 +279,20 @@ describe("Lifecycle CLI commands", () => {
|
||||
expect(logs.join("\n")).toContain("dev-qa@test");
|
||||
});
|
||||
|
||||
it("launch surfaces honest pod-aware rejection and exits non-zero", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(launchCommand(runningDeps()));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "launch", "rig-1", "dev.pod"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("bypasses startup orchestration");
|
||||
expect(logs.join("\n")).toContain("rig import --instantiate");
|
||||
});
|
||||
|
||||
it("--json prints raw unclaim payload", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DaemonClient, type DaemonResponse } from "../src/client.js";
|
||||
|
||||
function mockClient(overrides?: {
|
||||
get?: (path: string) => Promise<DaemonResponse>;
|
||||
post?: (path: string, body?: unknown) => Promise<DaemonResponse>;
|
||||
post?: (path: string, body?: unknown, options?: { timeoutMs?: number }) => Promise<DaemonResponse>;
|
||||
postText?: (path: string, text: string, contentType?: string, extraHeaders?: Record<string, string>) => Promise<DaemonResponse>;
|
||||
}): DaemonClient {
|
||||
const client = new DaemonClient("http://127.0.0.1:9999");
|
||||
@@ -81,12 +81,16 @@ describe("MCP Server", () => {
|
||||
arguments: { sourceRef: "/tmp/rig.yaml", plan: false, autoApprove: true },
|
||||
});
|
||||
|
||||
expect(postFn).toHaveBeenCalledWith("/api/up", {
|
||||
sourceRef: "/tmp/rig.yaml",
|
||||
plan: false,
|
||||
autoApprove: true,
|
||||
targetRoot: undefined,
|
||||
});
|
||||
expect(postFn).toHaveBeenCalledWith(
|
||||
"/api/up",
|
||||
{
|
||||
sourceRef: "/tmp/rig.yaml",
|
||||
plan: false,
|
||||
autoApprove: true,
|
||||
targetRoot: undefined,
|
||||
},
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
const content = result.content as Array<{ type: string; text: string }>;
|
||||
const parsed = JSON.parse(content[0].text);
|
||||
expect(parsed.status).toBe("completed");
|
||||
@@ -344,6 +348,7 @@ describe("MCP Server", () => {
|
||||
});
|
||||
|
||||
expect(postFn).toHaveBeenCalled();
|
||||
expect(postFn.mock.calls[0]?.[2]).toBeUndefined();
|
||||
expect(result.isError).toBeUndefined();
|
||||
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
@@ -351,6 +356,50 @@ describe("MCP Server", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("rig_send with waitForIdleSeconds extends request timeout", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 200,
|
||||
data: { ok: true, sessionName: "dev-impl@my-rig" },
|
||||
}));
|
||||
await setup({ post: postFn });
|
||||
|
||||
const result = await mcpClient.callTool({
|
||||
name: "rig_send",
|
||||
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30 },
|
||||
});
|
||||
|
||||
expect(postFn).toHaveBeenCalledWith(
|
||||
"/api/transport/send",
|
||||
expect.objectContaining({
|
||||
session: "dev-impl@my-rig",
|
||||
text: "hello",
|
||||
waitForIdleMs: 30000,
|
||||
}),
|
||||
{ timeoutMs: 35000 },
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("rig_send rejects waitForIdleSeconds with force before daemon contact", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 200,
|
||||
data: { ok: true },
|
||||
}));
|
||||
await setup({ post: postFn });
|
||||
|
||||
const result = await mcpClient.callTool({
|
||||
name: "rig_send",
|
||||
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30, force: true },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(postFn).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
||||
expect(parsed.error).toContain("cannot be combined");
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
// T-chatroom-send: rig_chatroom_send returns result
|
||||
it("rig_chatroom_send returns result", async () => {
|
||||
const getFn = vi.fn(async () => ({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_OPENRIG_HOME = process.env.OPENRIG_HOME;
|
||||
const ORIGINAL_RIGGED_HOME = process.env.RIGGED_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_OPENRIG_HOME === undefined) delete process.env.OPENRIG_HOME;
|
||||
else process.env.OPENRIG_HOME = ORIGINAL_OPENRIG_HOME;
|
||||
|
||||
if (ORIGINAL_RIGGED_HOME === undefined) delete process.env.RIGGED_HOME;
|
||||
else process.env.RIGGED_HOME = ORIGINAL_RIGGED_HOME;
|
||||
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("openrig-compat", () => {
|
||||
it("getOpenRigHome prefers OPENRIG_HOME when set", async () => {
|
||||
process.env.OPENRIG_HOME = "/tmp/custom-openrig-home";
|
||||
delete process.env.RIGGED_HOME;
|
||||
|
||||
const mod = await import("../src/openrig-compat.js");
|
||||
|
||||
expect(mod.getOpenRigHome()).toBe("/tmp/custom-openrig-home");
|
||||
expect(mod.getDefaultOpenRigPath("daemon.json")).toBe("/tmp/custom-openrig-home/daemon.json");
|
||||
});
|
||||
|
||||
it("getOpenRigHome falls back to RIGGED_HOME with warning", async () => {
|
||||
delete process.env.OPENRIG_HOME;
|
||||
process.env.RIGGED_HOME = "/tmp/legacy-rigged-home";
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const mod = await import("../src/openrig-compat.js");
|
||||
|
||||
expect(mod.getOpenRigHome()).toBe("/tmp/legacy-rigged-home");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"Warning: RIGGED_HOME is deprecated; use OPENRIG_HOME instead.",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 459 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user