@maolon/pi-watcher 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +255 -2
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +369 -0
- package/dist/contracts/interfaces.d.ts +262 -0
- package/dist/contracts/interfaces.js +1 -0
- package/dist/contracts/policy-defaults.json +61 -0
- package/dist/contracts/relay-next.interfaces.d.ts +160 -0
- package/dist/contracts/relay-next.interfaces.js +1 -0
- package/dist/engine/cards.d.ts +68 -0
- package/dist/engine/cards.js +76 -0
- package/dist/engine/engine.d.ts +178 -0
- package/dist/engine/engine.js +1162 -0
- package/dist/engine/hard-rules.d.ts +53 -0
- package/dist/engine/hard-rules.js +96 -0
- package/dist/engine/semantic.d.ts +49 -0
- package/dist/engine/semantic.js +125 -0
- package/dist/engine/service.d.ts +62 -0
- package/dist/engine/service.js +587 -0
- package/dist/engine/tool-actions.d.ts +35 -0
- package/dist/engine/tool-actions.js +348 -0
- package/dist/engine/widget.d.ts +29 -0
- package/dist/engine/widget.js +52 -0
- package/dist/ipc/client.d.ts +26 -0
- package/dist/ipc/client.js +106 -0
- package/dist/ipc/server.d.ts +78 -0
- package/dist/ipc/server.js +105 -0
- package/dist/jev/client.d.ts +38 -0
- package/dist/jev/client.js +239 -0
- package/dist/jev/consent.d.ts +12 -0
- package/dist/jev/consent.js +37 -0
- package/dist/jev/index.d.ts +9 -0
- package/dist/jev/index.js +9 -0
- package/dist/jev/mock.d.ts +30 -0
- package/dist/jev/mock.js +77 -0
- package/dist/jev/pi-registry.d.ts +66 -0
- package/dist/jev/pi-registry.js +127 -0
- package/dist/jev/questions.d.ts +15 -0
- package/dist/jev/questions.js +76 -0
- package/dist/jev/sanitizer.d.ts +10 -0
- package/dist/jev/sanitizer.js +59 -0
- package/dist/jev/types.d.ts +78 -0
- package/dist/jev/types.js +54 -0
- package/dist/pi-extension.d.ts +123 -0
- package/dist/pi-extension.js +687 -0
- package/dist/relay/managed.d.ts +120 -0
- package/dist/relay/managed.js +482 -0
- package/dist/relay/negotiate.d.ts +39 -0
- package/dist/relay/negotiate.js +112 -0
- package/dist/runtime.d.ts +74 -0
- package/dist/runtime.js +246 -0
- package/dist/source/agent-check/adapter.d.ts +57 -0
- package/dist/source/agent-check/adapter.js +224 -0
- package/dist/source/agent-file/adapter.d.ts +57 -0
- package/dist/source/agent-file/adapter.js +217 -0
- package/dist/source/task-status-v1/adapter.d.ts +36 -0
- package/dist/source/task-status-v1/adapter.js +263 -0
- package/dist/source/task-status-v1/producer.d.ts +81 -0
- package/dist/source/task-status-v1/producer.js +127 -0
- package/dist/storage/lock.d.ts +14 -0
- package/dist/storage/lock.js +66 -0
- package/dist/storage/schema.sql +166 -0
- package/dist/storage/store.d.ts +352 -0
- package/dist/storage/store.js +555 -0
- package/dist/util/clock.d.ts +23 -0
- package/dist/util/clock.js +34 -0
- package/dist/util/ids.d.ts +8 -0
- package/dist/util/ids.js +29 -0
- package/dist/util/result.d.ts +28 -0
- package/dist/util/result.js +54 -0
- package/package.json +101 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/). Before 1.0, minor versions may break.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - Unreleased
|
|
8
|
+
|
|
9
|
+
First public release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `watcher` tool with `watch-file`, `watch-check`, `register`, `list`, `inspect`, `check`, `pause` and `close`.
|
|
13
|
+
- `/watcher` command: status panel and local episode acknowledgement.
|
|
14
|
+
- Deterministic hard rules: explicit terminal states, business deadlines (raised once per checkpoint), silence detection and result cards.
|
|
15
|
+
- Optional semantic review via Jev (`semanticMode: shadow | active`). Credentials come from Pi's model registry (`/login` → TypeSafe, `TYPESAFE_API_KEY`, or another Jev provider) or `JEV_API_KEY`. Egress needs separate user consent: `/watcher jev consent on` or `JEV_CONSENT=1`.
|
|
16
|
+
- `/watcher jev` status command.
|
|
17
|
+
- Relay-delivered wakes through `@maolon/pi-relay` managed delivery: one scope per watch, durable control outbox, withdraw on pause/close with per-route results, and an idempotent host-response pump.
|
|
18
|
+
- `pi-watcher` CLI: `doctor`, `qualify`, `relay-setup`, plus `producer`/`demo` fixtures.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maolon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,256 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-watcher
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Watch long-running work from [Pi](https://pi.dev) without babysitting it. pi-watcher tracks builds, test runs,
|
|
4
|
+
training jobs, CI pipelines and deadlines from durable evidence. It keeps the session quiet while things progress
|
|
5
|
+
and wakes the session (through [pi-relay](https://github.com/Maolon/pi-relay)) only when a fact needs a decision.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@maolon/pi-relay # wake transport (recommended, see "pi-relay" below)
|
|
9
|
+
pi install npm:@maolon/pi-watcher
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
An agent that waits on a long task usually polls: `sleep`, re-run a status command, repeat. Each check spends a
|
|
15
|
+
model turn and grows the context just to learn "still running". pi-watcher moves that loop out of the model:
|
|
16
|
+
|
|
17
|
+
- **Facts first.** Explicit terminal states, exit markers, deadlines and silence are decided by code, not by a model.
|
|
18
|
+
- **Quiet by default.** Routine progress only updates a status widget. Nothing enters the conversation.
|
|
19
|
+
- **Wake on decisions.** Failure, a crossed deadline, a ready dependency or (optionally) a semantic blocker
|
|
20
|
+
becomes one episode and one wake. The host then inspects the fresh facts and responds.
|
|
21
|
+
- **Honest state.** Unknown stays unknown. Delivery, withdrawal and completion are reported only with evidence.
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
exec log / build output / CI status Pi session
|
|
27
|
+
│ ▲
|
|
28
|
+
watch-file │ watch-check │ wake (managed delivery)
|
|
29
|
+
▼ │
|
|
30
|
+
pi-watcher engine ──► SQLite truth store ──► pi-relay ──► relay_respond ──► watcher applies the response
|
|
31
|
+
hard rules, deadlines, silence,
|
|
32
|
+
optional Jev semantic review
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Each Pi session runs its own watcher runtime with state under `<cwd>/.pi-watcher/sessions/<session-id>/`.
|
|
36
|
+
The watcher never runs, retries, kills or fixes the task it watches.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Pi 1.0 or newer (`@earendil-works/pi-coding-agent`); Jev through Pi's `/login` needs Pi 1.1+
|
|
41
|
+
- Node.js 22.16 or newer
|
|
42
|
+
- macOS or Linux. Windows is not supported.
|
|
43
|
+
- A native build toolchain for `fs-ext` (Python 3, `make`, a C++ compiler; on macOS the Xcode Command Line
|
|
44
|
+
Tools). `better-sqlite3` ships prebuilt binaries for common platforms. pi-relay has the same requirements.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pi install npm:@maolon/pi-relay
|
|
50
|
+
pi install npm:@maolon/pi-watcher
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use `-l` to install into the current project (`.pi/settings.json`) instead of your personal settings, or try it
|
|
54
|
+
for one run with `pi -e npm:@maolon/pi-watcher`. Pin versions with `npm:@maolon/pi-watcher@0.1.0`.
|
|
55
|
+
|
|
56
|
+
Add the watcher's state directories to your project's `.gitignore`:
|
|
57
|
+
|
|
58
|
+
```gitignore
|
|
59
|
+
.pi-watcher/
|
|
60
|
+
.pi-watcher-sources/
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## pi-relay
|
|
64
|
+
|
|
65
|
+
pi-watcher uses [`@maolon/pi-relay`](https://www.npmjs.com/package/@maolon/pi-relay) in two different roles:
|
|
66
|
+
|
|
67
|
+
| Role | What it is | How you get it |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| **Library** | The watcher publishes attentions, withdraws them on pause/close and reads host responses through pi-relay's managed-delivery source API (`@maolon/pi-relay/source`, `/consumer`, `/protocol`). | A regular npm dependency of pi-watcher, installed automatically. |
|
|
70
|
+
| **Pi extension** | Runs inside your Pi session as the delivery target: receives the wake, injects it when the session is idle, and gives the model the `relay_respond` tool. | Install it yourself: `pi install npm:@maolon/pi-relay`. |
|
|
71
|
+
|
|
72
|
+
**Compatibility.** pi-watcher 0.1.x requires pi-relay **0.2.x**: the `^0.2.0` dependency for the library, and
|
|
73
|
+
0.2.x for the extension you install. Both sides share the relay home on disk (`~/.pi/relay`, or `PI_RELAY_HOME`),
|
|
74
|
+
so keep them on the same minor version.
|
|
75
|
+
|
|
76
|
+
**Without the pi-relay extension**, the watcher still works, but in *local display* mode:
|
|
77
|
+
|
|
78
|
+
| | with pi-relay | without pi-relay |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| Watches, hard rules, deadlines, silence | yes | yes |
|
|
81
|
+
| Status widget and toasts in the session | yes | yes |
|
|
82
|
+
| Result cards, `inspect`, `/watcher` panel | yes | yes |
|
|
83
|
+
| Wake an idle session when a fact needs a decision | **yes** | no: you or the model must look |
|
|
84
|
+
| Host response loop (`relay_respond` → applied) | yes | no (`ack` returns `NO_DELIVERY`; use `/watcher ack`) |
|
|
85
|
+
| Withdraw pending wakes on `pause`/`close` | yes (per-route result) | nothing is in flight |
|
|
86
|
+
|
|
87
|
+
**Setup is automatic.** When the relay home exists, the watcher enables relay delivery. On session start it asks the
|
|
88
|
+
pi-relay extension in the same session for a local binding over Pi's event bus, then provisions its audience and
|
|
89
|
+
scope. No invite or manual step is needed. The binding is *local trust*: same machine, same OS user, no interactive
|
|
90
|
+
consent prompt. To opt out:
|
|
91
|
+
|
|
92
|
+
- `PI_WATCHER_RELAY=0`: never use relay (local display only).
|
|
93
|
+
- `PI_WATCHER_AUTO_BIND=0`: keep relay, but do not bind automatically. Use `pi-watcher relay-setup` instead.
|
|
94
|
+
|
|
95
|
+
**When a wake arrives**, the model is instructed to run `watcher inspect` for that watch first, act on the fresh
|
|
96
|
+
facts, and then answer with `relay_respond` (`received`, `investigating`, `defer`, `resolved` or `dismiss`). The
|
|
97
|
+
watcher applies the response to the episode, confirms the application back to the relay, and closes finished
|
|
98
|
+
watches shortly afterwards.
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
You normally just ask: *"run the test suite and tell me when it's done"*, *"watch the training run"*, *"keep an eye
|
|
103
|
+
on the CI for this PR"*. The tool guidelines steer the model to the `watcher` tool instead of polling.
|
|
104
|
+
|
|
105
|
+
### Watch a file
|
|
106
|
+
|
|
107
|
+
Typical use: the log of a long `exec_command` session.
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{ "action": "watch-file", "path": "/tmp/build.log",
|
|
111
|
+
"okPattern": "__EXEC_EXIT__:0", "failPattern": "__EXEC_EXIT__:-?[1-9]",
|
|
112
|
+
"objective": "release build", "deadlineAt": "2026-10-08T18:00:00Z" }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A terminal state is decided only by the declared patterns (fail is checked first). End of file, a quiet log or a
|
|
116
|
+
line that merely says "done" never counts as completion.
|
|
117
|
+
|
|
118
|
+
### Watch a command
|
|
119
|
+
|
|
120
|
+
Use this for state you cannot tail: CI, cloud resources, remote health.
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{ "action": "watch-check", "cmd": "gh run view 123456 --json conclusion -q .conclusion",
|
|
124
|
+
"okPattern": "^success$", "failPattern": "^(failure|cancelled)$", "intervalMs": 60000 }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The command runs on a fixed interval with a timeout (1–30 s). Exit codes 126/127 and timeouts mark the *monitor* as
|
|
128
|
+
degraded; they never mark the task as failed. The command text is stored as evidence, so never put secrets in it.
|
|
129
|
+
|
|
130
|
+
### Other actions
|
|
131
|
+
|
|
132
|
+
| Action | Purpose |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `list` | Active and paused watches (`includeClosed: true` for history) |
|
|
135
|
+
| `inspect` | Authoritative facts for one watch: state, episodes, health, delivery |
|
|
136
|
+
| `check` | Schedule one bounded refresh now (needs `expectedControlRevision`) |
|
|
137
|
+
| `pause` / `close` | Stop watching. Pending wakes are withdrawn; the result is reported per route. |
|
|
138
|
+
| `register` | Full spec: `run`, `group` (up to 16 members) and `obligation` targets |
|
|
139
|
+
|
|
140
|
+
`/watcher` shows the panel; `/watcher ack <episodeId> <received|investigating|defer|resolved|dismiss> [until]`
|
|
141
|
+
handles an episode locally; `/watcher jev` manages the optional semantic review (see below).
|
|
142
|
+
|
|
143
|
+
### Exit markers for `exec_command`
|
|
144
|
+
|
|
145
|
+
To make shell-session outcomes decidable, the watcher appends `echo __EXEC_EXIT__:$?` to every `exec_command`
|
|
146
|
+
call. It creates no watch on its own. Disable it with `PI_WATCHER_EXEC_MARKER=0`. To have every long-running exec
|
|
147
|
+
session watched automatically, set `PI_WATCHER_AUTO_EXEC=1` (off by default).
|
|
148
|
+
|
|
149
|
+
## Semantic review (Jev, optional)
|
|
150
|
+
|
|
151
|
+
**Optional and off by default.** pi-watcher works fully without it: every watch is decided by facts (patterns,
|
|
152
|
+
exit markers, deadlines, silence). Semantic review is an extra layer you opt into per watch.
|
|
153
|
+
|
|
154
|
+
Some tasks cannot be judged by patterns alone: a training run that stalls, a fix loop that keeps repeating, a log
|
|
155
|
+
that claims success while showing errors. With `semanticMode` set on a watch, the watcher asks
|
|
156
|
+
[Jev](https://typesafe.ai), TypeSafe's fast discriminative classifier (not a chat model), six bounded questions
|
|
157
|
+
about a sanitized evidence window: progress, unresolved blocker, needs host decision, repeating without new
|
|
158
|
+
information, claim vs evidence conflict, and context sufficiency.
|
|
159
|
+
|
|
160
|
+
- `off` (default): facts only.
|
|
161
|
+
- `shadow`: record judgments and show them as toasts and widget hints; never wake.
|
|
162
|
+
- `active`: a confident blocker, decision request, repeating loop or claim conflict opens an episode and can wake the session.
|
|
163
|
+
|
|
164
|
+
Enabling it takes two separate steps, because authentication and permission to send data are different decisions.
|
|
165
|
+
|
|
166
|
+
**1. Authenticate Jev through Pi** (Pi 1.1+ ships Jev as a classifier model). Use any one of these:
|
|
167
|
+
|
|
168
|
+
- In Pi, run `/login`, choose **Sign in with an API key**, then pick **TypeSafe** (or another provider that serves
|
|
169
|
+
Jev, such as OpenRouter or OpenCode). The credential lands in Pi's `auth.json` and the watcher picks it up,
|
|
170
|
+
even mid-session.
|
|
171
|
+
- `export TYPESAFE_API_KEY=...` (Pi's standard variable for TypeSafe).
|
|
172
|
+
- `export JEV_API_KEY=...` uses the watcher's own direct client, pinned to `jev-1.13.0`. It takes precedence over Pi's registry.
|
|
173
|
+
|
|
174
|
+
When several Jev providers are configured, the watcher prefers TypeSafe direct, then OpenRouter, OpenCode,
|
|
175
|
+
Cloudflare Workers AI and Vercel AI Gateway. Force one with `PI_WATCHER_JEV_MODEL=<provider>/<model>`, for example
|
|
176
|
+
`openrouter/typesafe/jev-1.13`. The model that actually answered is recorded with every judgment.
|
|
177
|
+
|
|
178
|
+
**2. Grant egress consent** (user only; the model cannot grant it):
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
/watcher jev consent on # stored in ~/.pi/agent/pi-watcher.json; `off` revokes it
|
|
182
|
+
/watcher jev # shows the active Jev source and the consent state
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Alternatively set `JEV_CONSENT=1` (or `0` to force it off); the environment overrides the stored setting.
|
|
186
|
+
|
|
187
|
+
Without credentials, a watch with `semanticMode` shows "semantic review unavailable" with the reason, and the facts
|
|
188
|
+
keep working. Before anything is sent, credentials and tokens are redacted. Requests are budgeted per watch and
|
|
189
|
+
per day, and hard facts always win over model scores. Redaction is best effort: for sensitive repositories, leave
|
|
190
|
+
`semanticMode` off.
|
|
191
|
+
|
|
192
|
+
## Configuration
|
|
193
|
+
|
|
194
|
+
| Variable | Default | Effect |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `PI_RELAY_HOME` | `~/.pi/relay` | Relay home shared with the pi-relay extension |
|
|
197
|
+
| `PI_WATCHER_RELAY` | auto | `0` disables relay delivery |
|
|
198
|
+
| `PI_WATCHER_AUTO_BIND` | on | `0` disables automatic local binding |
|
|
199
|
+
| `PI_WATCHER_EXEC_MARKER` | on | `0` disables `__EXEC_EXIT__` injection |
|
|
200
|
+
| `PI_WATCHER_AUTO_EXEC` | off | `1` auto-watches long-running exec sessions |
|
|
201
|
+
| `TYPESAFE_API_KEY` | unset | Pi's TypeSafe credential; enables Jev through Pi (same as `/login` → TypeSafe) |
|
|
202
|
+
| `JEV_API_KEY` | unset | Watcher's direct Jev client (`jev-1.13.0`); takes precedence over Pi's registry |
|
|
203
|
+
| `PI_WATCHER_JEV_MODEL` | auto | Force a Pi Jev model, e.g. `openrouter/typesafe/jev-1.13` |
|
|
204
|
+
| `JEV_CONSENT` | unset | `1`/`0` overrides the stored `/watcher jev consent` setting |
|
|
205
|
+
| `PI_WATCHER_CONFIG` | `~/.pi/agent/pi-watcher.json` | Where `/watcher jev consent` is stored |
|
|
206
|
+
| `JEV_BASE_URL` | `https://api.typesafe.ai` | Endpoint for the direct client |
|
|
207
|
+
| `JEV_TIMEOUT_MS` | `10000` | Per-request timeout for the direct client |
|
|
208
|
+
|
|
209
|
+
## Data and privacy
|
|
210
|
+
|
|
211
|
+
- All watcher state stays on your machine: SQLite (WAL, `synchronous=FULL`) under `<cwd>/.pi-watcher/`.
|
|
212
|
+
- Wakes travel through pi-relay's store under the relay home on the same machine.
|
|
213
|
+
- Shadow-mode judgments are shown as session toasts and widget hints, never as conversation messages.
|
|
214
|
+
- Only with Jev credentials **and** egress consent (`/watcher jev consent on` or `JEV_CONSENT=1`) does a sanitized,
|
|
215
|
+
size-bounded evidence window leave the machine, and only for watches with `semanticMode` set.
|
|
216
|
+
- `watch-file` reads the paths you or the model declare. `watch-check` runs the declared command as your user.
|
|
217
|
+
Both are as powerful as the session itself; review what the model registers.
|
|
218
|
+
|
|
219
|
+
## CLI
|
|
220
|
+
|
|
221
|
+
The package also installs `pi-watcher`:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
pi-watcher doctor --root .pi-watcher # local diagnostics
|
|
225
|
+
pi-watcher relay-setup [--finalize] # manual relay owner setup (when auto-bind is off)
|
|
226
|
+
pi-watcher qualify # native dependency / storage qualification report
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Status
|
|
230
|
+
|
|
231
|
+
0.1 is an early release. What is solid and what is not:
|
|
232
|
+
|
|
233
|
+
- **Solid:** fact-driven watches (`watch-file`, `watch-check`, task-status), deadlines, silence, result cards,
|
|
234
|
+
per-session isolation, relay wakes with withdraw-on-pause, idempotent host-response handling. Covered by the
|
|
235
|
+
offline suite, including end-to-end tests against the real pi-relay libraries.
|
|
236
|
+
- **Experimental:** semantic review thresholds are initial values, not calibrated results. Use `shadow` before `active`.
|
|
237
|
+
- **Not yet:** a standalone watcher service (the watcher runs inside the Pi session), an interactive consent prompt
|
|
238
|
+
for relay binding (it is local trust today), fork/tree-navigation holds, artifact-digest binding for checks,
|
|
239
|
+
evidence retention limits, and the `update` action.
|
|
240
|
+
|
|
241
|
+
## Development
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm install
|
|
245
|
+
npm run typecheck
|
|
246
|
+
npm run test:unit # offline suite
|
|
247
|
+
npm run build # dist/
|
|
248
|
+
npm run test:package # pack + leak scan + install + load with plain Node
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
To load your working copy in Pi: `pi -e ./` from this directory after `npm run build`. See [AGENTS.md](AGENTS.md)
|
|
252
|
+
for conventions.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
[MIT](LICENSE)
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-watcher CLI (design 11.2 shell entry point).
|
|
3
|
+
* - qualify: V0 qualification check, produces qualification.json (distinguishes candidate platforms from measured platforms)
|
|
4
|
+
* - doctor: local diagnostics (lock/DB/relay negotiation/panel summary)
|
|
5
|
+
* - producer: runs a real async task-status-v1 fixture producer
|
|
6
|
+
* - demo: V1 display-only end-to-end demo
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pi-watcher CLI (design 11.2 shell entry point).
|
|
4
|
+
* - qualify: V0 qualification check, produces qualification.json (distinguishes candidate platforms from measured platforms)
|
|
5
|
+
* - doctor: local diagnostics (lock/DB/relay negotiation/panel summary)
|
|
6
|
+
* - producer: runs a real async task-status-v1 fixture producer
|
|
7
|
+
* - demo: V1 display-only end-to-end demo
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as os from 'node:os';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { spawn, execSync } from 'node:child_process';
|
|
13
|
+
import { startRuntime } from './runtime.js';
|
|
14
|
+
import { runScenario, scenarioSteps } from './source/task-status-v1/producer.js';
|
|
15
|
+
import { negotiateRelay } from './relay/negotiate.js';
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const [cmd, ...rest] = argv;
|
|
18
|
+
const flags = new Map();
|
|
19
|
+
for (let i = 0; i < rest.length; i++) {
|
|
20
|
+
if (rest[i].startsWith('--')) {
|
|
21
|
+
const key = rest[i].slice(2);
|
|
22
|
+
const next = rest[i + 1];
|
|
23
|
+
if (next && !next.startsWith('--')) {
|
|
24
|
+
flags.set(key, next);
|
|
25
|
+
i += 1;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
flags.set(key, 'true');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { cmd: cmd ?? '', flags };
|
|
33
|
+
}
|
|
34
|
+
async function main() {
|
|
35
|
+
const { cmd, flags } = parseArgs(process.argv.slice(2));
|
|
36
|
+
switch (cmd) {
|
|
37
|
+
case 'qualify':
|
|
38
|
+
await cmdQualify(flags);
|
|
39
|
+
break;
|
|
40
|
+
case 'doctor':
|
|
41
|
+
await cmdDoctor(flags);
|
|
42
|
+
break;
|
|
43
|
+
case 'producer':
|
|
44
|
+
await cmdProducer(flags);
|
|
45
|
+
break;
|
|
46
|
+
case 'demo':
|
|
47
|
+
await cmdDemo(flags);
|
|
48
|
+
break;
|
|
49
|
+
case 'relay-setup':
|
|
50
|
+
await cmdRelaySetup(flags);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
console.log('usage: pi-watcher <qualify|doctor|producer|demo|relay-setup> [options]');
|
|
54
|
+
console.log(' qualify [--out qualification.json] V0 qualification check, writes evidence file');
|
|
55
|
+
console.log(' doctor [--root DIR] local diagnostics');
|
|
56
|
+
console.log(' producer --dir D --scenario build-success|build-failure [--interval 200] [--run-id run-1]');
|
|
57
|
+
console.log(' demo V1 display-only end-to-end demo');
|
|
58
|
+
console.log(' relay-setup [--root DIR] [--relay-home DIR] [--finalize] relay consumer-side owner setup');
|
|
59
|
+
process.exit(cmd ? 2 : 0);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function cmdQualify(flags) {
|
|
63
|
+
const checks = [];
|
|
64
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-qual-'));
|
|
65
|
+
// 1. SQLite WAL/FULL/FK (measured with native better-sqlite3)
|
|
66
|
+
try {
|
|
67
|
+
const Database = (await import('better-sqlite3')).default;
|
|
68
|
+
const db = new Database(path.join(tmp, 'qual.db'));
|
|
69
|
+
const jm = db.pragma('journal_mode = WAL', { simple: true });
|
|
70
|
+
db.pragma('synchronous = FULL');
|
|
71
|
+
db.pragma('foreign_keys = ON');
|
|
72
|
+
const sync = db.pragma('synchronous', { simple: true });
|
|
73
|
+
const fk = db.pragma('foreign_keys', { simple: true });
|
|
74
|
+
db.close();
|
|
75
|
+
checks.push({
|
|
76
|
+
check: 'sqlite-native-wal-full-fk',
|
|
77
|
+
passed: jm === 'wal' && sync === 2 && fk === 1,
|
|
78
|
+
detail: `journal_mode=${jm}, synchronous=${sync} (2=FULL), foreign_keys=${fk}`
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
checks.push({ check: 'sqlite-native-wal-full-fk', passed: false, detail: String(e) });
|
|
83
|
+
}
|
|
84
|
+
// 2. flock fixed-inode mutual exclusion (child process holds the lock)
|
|
85
|
+
try {
|
|
86
|
+
const lockPath = path.join(tmp, 'watcher.lock');
|
|
87
|
+
fs.writeFileSync(lockPath, '', 'utf8');
|
|
88
|
+
const holder = `
|
|
89
|
+
const fs = require('node:fs');
|
|
90
|
+
const { flock } = require('fs-ext');
|
|
91
|
+
const fd = fs.openSync(${JSON.stringify(lockPath)}, 'a');
|
|
92
|
+
flock(fd, 'ex', () => {
|
|
93
|
+
console.log('held');
|
|
94
|
+
setTimeout(() => process.exit(0), 4000);
|
|
95
|
+
});
|
|
96
|
+
`;
|
|
97
|
+
const child = spawnNode(['-e', holder]);
|
|
98
|
+
await waitStdout(child, 'held');
|
|
99
|
+
const fsExt = await import('fs-ext');
|
|
100
|
+
const fsMod = await import('node:fs');
|
|
101
|
+
const fd2 = fsMod.openSync(lockPath, 'a');
|
|
102
|
+
const rejected = await new Promise(resolve => {
|
|
103
|
+
fsExt.flock(fd2, 'exnb', (err) => {
|
|
104
|
+
resolve(!!err);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
fsMod.closeSync(fd2);
|
|
108
|
+
child.kill('SIGKILL');
|
|
109
|
+
checks.push({
|
|
110
|
+
check: 'root-flock-exclusive-contention',
|
|
111
|
+
passed: rejected,
|
|
112
|
+
detail: rejected ? 'second exclusive non-blocking flock rejected (EAGAIN)' : 'second flock unexpectedly acquired'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
checks.push({ check: 'root-flock-exclusive-contention', passed: false, detail: String(e) });
|
|
117
|
+
}
|
|
118
|
+
// 3. storage.sql schema constraints (triggers + uniqueness + partial unique indexes)
|
|
119
|
+
try {
|
|
120
|
+
const { WatchStore } = await import('./storage/store.js');
|
|
121
|
+
const store = await WatchStore.open(path.join(tmp, 'root3'), { mode: 'embedded' });
|
|
122
|
+
const violations = [];
|
|
123
|
+
// outbox identity immutable
|
|
124
|
+
try {
|
|
125
|
+
store.transaction(tx => {
|
|
126
|
+
tx.insertWatch({
|
|
127
|
+
watchId: 'wq', generation: 1, missionRevision: 1, controlRevision: 1,
|
|
128
|
+
lifecycle: 'active', health: 'healthy', ownerSession: 's', ownerBindingEpoch: 1,
|
|
129
|
+
spec: { schemaVersion: 1, watchId: 'wq', generation: 1, missionRevision: 1, controlRevision: 1, mode: 'embedded',
|
|
130
|
+
owner: { sessionId: 's', originAnchor: null, bindingEpoch: 1, profileId: 'p' },
|
|
131
|
+
target: { kind: 'run', sourceId: 'src', taskId: 't', runId: 'r', attemptId: 'a' },
|
|
132
|
+
mission: { objective: 'o', scope: 's', checkpointId: 'c', requiredArtifacts: [], requiresChecks: false, businessAcceptance: 'not_required' },
|
|
133
|
+
policy: { transport: 'local-display', semanticMode: 'off', notificationOwner: 'watcher', requestKinds: [], maxRequestsPerEpisode: 1, episodeCooldownMs: 60000, attentionTtlMs: 120000 },
|
|
134
|
+
limits: { pollMinMs: 1000, pollMaxMs: 30000, maxSilenceMs: 60000, maxProbesPerEpisode: 2, maxJudgeRequestsPerDay: 10, expiresAt: new Date(Date.now() + 3600_000).toISOString() } },
|
|
135
|
+
snapshot: { taskState: 'unknown', coverage: { truncated: false, sourceGap: false }, scopeRevision: 0, backoffMs: 0 },
|
|
136
|
+
nextDueAt: Date.now(), now: Date.now()
|
|
137
|
+
});
|
|
138
|
+
tx.insertOutbox({
|
|
139
|
+
eventId: 'ev1', watchId: 'wq', episodeId: null, generation: 1, eventType: 'watcher.result.v1',
|
|
140
|
+
eventBytes: Buffer.from('{}'), eventDigest: 'd', validUntil: Date.now() + 1000, now: Date.now()
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
let immutableRejected = false;
|
|
144
|
+
try {
|
|
145
|
+
store.transaction(tx => {
|
|
146
|
+
tx.setOutboxAdmission('ev1', 'pending', {}, Date.now());
|
|
147
|
+
tx.mutateOutbox?.();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
immutableRejected = true;
|
|
152
|
+
}
|
|
153
|
+
if (!immutableRejected) {
|
|
154
|
+
// Verify triggers directly with SQL
|
|
155
|
+
const Database = (await import('better-sqlite3')).default;
|
|
156
|
+
const db = new Database(path.join(tmp, 'root3', 'watcher.db'));
|
|
157
|
+
try {
|
|
158
|
+
db.prepare("UPDATE outbox SET event_bytes = ? WHERE event_id = ?").run(Buffer.from('{"x":1}'), 'ev1');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
immutableRejected = true;
|
|
162
|
+
}
|
|
163
|
+
db.close();
|
|
164
|
+
}
|
|
165
|
+
if (!immutableRejected)
|
|
166
|
+
violations.push('outbox identity trigger did not reject mutation');
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
violations.push(`setup: ${String(e)}`);
|
|
170
|
+
}
|
|
171
|
+
store.close();
|
|
172
|
+
checks.push({
|
|
173
|
+
check: 'storage-schema-constraints',
|
|
174
|
+
passed: violations.length === 0,
|
|
175
|
+
detail: violations.length === 0 ? 'outbox identity immutability enforced by trigger' : violations.join('; ')
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
checks.push({ check: 'storage-schema-constraints', passed: false, detail: String(e) });
|
|
180
|
+
}
|
|
181
|
+
// 4. relay 1.2 feature negotiation
|
|
182
|
+
const negotiation = await negotiateRelay();
|
|
183
|
+
checks.push({
|
|
184
|
+
check: 'relay-1-2-feature-negotiation',
|
|
185
|
+
passed: negotiation.status === 'unavailable' || negotiation.status === 'managed-1-2',
|
|
186
|
+
detail: `status=${negotiation.status}; transport=${negotiation.transport}; ${negotiation.detail}`
|
|
187
|
+
});
|
|
188
|
+
// 5. runtime versions
|
|
189
|
+
checks.push({
|
|
190
|
+
check: 'runtime-versions',
|
|
191
|
+
passed: true,
|
|
192
|
+
detail: `node=${process.versions.node}, platform=${os.platform()}/${os.arch()}`
|
|
193
|
+
});
|
|
194
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
195
|
+
const out = {
|
|
196
|
+
kind: 'V0_QUALIFICATION',
|
|
197
|
+
date: new Date().toISOString(),
|
|
198
|
+
commit: gitHead(),
|
|
199
|
+
machine: `${os.platform()}/${os.arch()} node ${process.versions.node}`,
|
|
200
|
+
candidatePlatform: 'POSIX (darwin) local',
|
|
201
|
+
testedPlatform: `${os.platform()} local`,
|
|
202
|
+
checks,
|
|
203
|
+
passed: checks.every(c => c.passed),
|
|
204
|
+
note: 'V0 qualification only; not product acceptance (see design 14).'
|
|
205
|
+
};
|
|
206
|
+
const outPath = flags.get('out') ?? 'qualification/qualification.json';
|
|
207
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
208
|
+
fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
|
|
209
|
+
console.log(JSON.stringify(out, null, 2));
|
|
210
|
+
process.exit(out.passed ? 0 : 5);
|
|
211
|
+
}
|
|
212
|
+
function gitHead() {
|
|
213
|
+
try {
|
|
214
|
+
return execSync('git rev-parse HEAD', { cwd: process.cwd(), encoding: 'utf8' }).trim();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function spawnNode(args) {
|
|
221
|
+
return spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
222
|
+
}
|
|
223
|
+
async function waitStdout(child, needle, timeoutMs = 5000) {
|
|
224
|
+
await new Promise((resolve, reject) => {
|
|
225
|
+
let buf = '';
|
|
226
|
+
const timer = setTimeout(() => reject(new Error('timeout waiting for holder lock')), timeoutMs);
|
|
227
|
+
child.stdout?.on('data', (chunk) => {
|
|
228
|
+
buf += chunk.toString('utf8');
|
|
229
|
+
if (buf.includes(needle)) {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
resolve();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
child.on('exit', code => {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
reject(new Error(`holder exited early: ${code}`));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// --- doctor ---
|
|
241
|
+
async function cmdDoctor(flags) {
|
|
242
|
+
const root = path.resolve(flags.get('root') ?? './.pi-watcher');
|
|
243
|
+
const negotiation = await negotiateRelay();
|
|
244
|
+
const report = {
|
|
245
|
+
root,
|
|
246
|
+
lockFile: fs.existsSync(path.join(root, 'watcher.lock')),
|
|
247
|
+
dbFile: fs.existsSync(path.join(root, 'watcher.db')),
|
|
248
|
+
relay: negotiation,
|
|
249
|
+
versions: { node: process.versions.node }
|
|
250
|
+
};
|
|
251
|
+
console.log(JSON.stringify(report, null, 2));
|
|
252
|
+
}
|
|
253
|
+
// --- producer ---
|
|
254
|
+
async function cmdProducer(flags) {
|
|
255
|
+
const dir = flags.get('dir');
|
|
256
|
+
const scenario = (flags.get('scenario') ?? 'build-success');
|
|
257
|
+
const interval = parseInt(flags.get('interval') ?? '200', 10);
|
|
258
|
+
const runId = flags.get('run-id') ?? `run-${Date.now()}`;
|
|
259
|
+
if (!dir) {
|
|
260
|
+
console.error('--dir required');
|
|
261
|
+
process.exit(2);
|
|
262
|
+
}
|
|
263
|
+
const producer = await runScenario(dir, { sourceId: 'executor-local', taskId: 'build', runId, attemptId: 'attempt-1' }, scenarioSteps(scenario, interval));
|
|
264
|
+
console.log(JSON.stringify({ done: true, scenario, dir, runId, lastSnapshot: producer.lastSnapshot }, null, 2));
|
|
265
|
+
}
|
|
266
|
+
// --- demo (V1 display-only) ---
|
|
267
|
+
async function cmdDemo(_flags) {
|
|
268
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-demo-'));
|
|
269
|
+
const sourceRoot = path.join(tmp, 'sources');
|
|
270
|
+
const rootDir = path.join(tmp, 'state');
|
|
271
|
+
const runtime = await startRuntime({
|
|
272
|
+
rootDir,
|
|
273
|
+
sourceRoots: new Map([['executor-local', sourceRoot]]),
|
|
274
|
+
requiredCheckIds: new Map([['executor-local', ['unit-tests']]]),
|
|
275
|
+
pollTickMs: 100
|
|
276
|
+
});
|
|
277
|
+
runtime.startLoop();
|
|
278
|
+
const scenarioP = runScenario(sourceRoot, { sourceId: 'executor-local', taskId: 'build', runId: 'run-demo-1', attemptId: 'attempt-1' }, scenarioSteps('build-failure', 150));
|
|
279
|
+
await scenarioP;
|
|
280
|
+
await new Promise(r => setTimeout(r, 700));
|
|
281
|
+
const actor = {
|
|
282
|
+
actorId: 'demo',
|
|
283
|
+
owner: { sessionId: 'demo-session', originAnchor: null, bindingEpoch: 1, profileId: 'demo' },
|
|
284
|
+
profileRevision: 1,
|
|
285
|
+
permitted: new Set()
|
|
286
|
+
};
|
|
287
|
+
const spec = await runtime.service.register(`req-${Date.now()}`, {
|
|
288
|
+
schemaVersion: 1,
|
|
289
|
+
mode: 'embedded',
|
|
290
|
+
target: { kind: 'run', sourceId: 'executor-local', taskId: 'build', runId: 'run-demo-1', attemptId: 'attempt-1' },
|
|
291
|
+
mission: {
|
|
292
|
+
objective: 'Run the existing async build task; observe status and report on failure or deliverable results.',
|
|
293
|
+
scope: 'Observe run-demo-1 only; do not modify, retry, or terminate the build.',
|
|
294
|
+
checkpointId: 'compile-and-test',
|
|
295
|
+
requiredArtifacts: ['artifact-app'],
|
|
296
|
+
requiresChecks: true,
|
|
297
|
+
businessAcceptance: 'host'
|
|
298
|
+
},
|
|
299
|
+
policy: {
|
|
300
|
+
transport: 'local-display',
|
|
301
|
+
semanticMode: 'off',
|
|
302
|
+
notificationOwner: 'watcher',
|
|
303
|
+
requestKinds: ['task.failed', 'task.terminal'],
|
|
304
|
+
maxRequestsPerEpisode: 1,
|
|
305
|
+
episodeCooldownMs: 60000,
|
|
306
|
+
attentionTtlMs: 120000
|
|
307
|
+
},
|
|
308
|
+
limits: {
|
|
309
|
+
pollMinMs: 1000,
|
|
310
|
+
pollMaxMs: 5000,
|
|
311
|
+
maxSilenceMs: 60000,
|
|
312
|
+
maxProbesPerEpisode: 2,
|
|
313
|
+
maxJudgeRequestsPerDay: 10,
|
|
314
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString()
|
|
315
|
+
}
|
|
316
|
+
}, actor);
|
|
317
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
318
|
+
const listed = await runtime.service.list(undefined, 50, actor);
|
|
319
|
+
const inspected = await runtime.service.inspect(spec.watchId, actor);
|
|
320
|
+
console.log('=== demo register ===');
|
|
321
|
+
console.log(JSON.stringify({ watchId: spec.watchId }, null, 2));
|
|
322
|
+
console.log('=== list ===');
|
|
323
|
+
console.log(JSON.stringify(listed, null, 2));
|
|
324
|
+
console.log('=== inspect ===');
|
|
325
|
+
console.log(JSON.stringify(inspected, null, 2));
|
|
326
|
+
runtime.close();
|
|
327
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
main().catch(e => {
|
|
330
|
+
console.error(e);
|
|
331
|
+
process.exit(5);
|
|
332
|
+
});
|
|
333
|
+
// --- relay-setup (owner side: consumer declaration + invite + audience/scope configuration) ---
|
|
334
|
+
async function cmdRelaySetup(flags) {
|
|
335
|
+
const { WatcherRelaySource } = await import('./relay/managed.js');
|
|
336
|
+
const cwd = process.cwd();
|
|
337
|
+
const rootDir = path.resolve(flags.get('root') ?? path.join(cwd, '.pi-watcher', 'relay'));
|
|
338
|
+
const relayHome = path.resolve(flags.get('relay-home') ?? process.env.PI_RELAY_HOME ?? path.join(os.homedir(), '.pi', 'relay'));
|
|
339
|
+
const finalize = flags.get('finalize') === 'true';
|
|
340
|
+
const relay = await WatcherRelaySource.open(rootDir, { relayHome });
|
|
341
|
+
const decl = relay.ensureConsumerDeclaration();
|
|
342
|
+
console.log(JSON.stringify({
|
|
343
|
+
step: 'consumer-declaration',
|
|
344
|
+
file: decl.file,
|
|
345
|
+
note: 'Pi sessions (with the relay extension) scan <relay-home>/consumers at startup and register the guard'
|
|
346
|
+
}, null, 2));
|
|
347
|
+
if (!finalize) {
|
|
348
|
+
const invite = relay.createInvite();
|
|
349
|
+
console.log(JSON.stringify({
|
|
350
|
+
step: 'invite',
|
|
351
|
+
inviteFile: invite.inviteFile,
|
|
352
|
+
note: 'Have the host-session model call the relay_bindings tool: action=bind, invitePath=<file above> (single-use), then run relay-setup --finalize'
|
|
353
|
+
}, null, 2));
|
|
354
|
+
await relay.close();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!relay.hasActiveMembership()) {
|
|
358
|
+
console.log(JSON.stringify({
|
|
359
|
+
step: 'finalize-blocked',
|
|
360
|
+
reason: 'no membership yet — run bind first (see invite step), then retry --finalize',
|
|
361
|
+
status: relay.statusJson()
|
|
362
|
+
}, null, 2));
|
|
363
|
+
await relay.close();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const prov = relay.provision();
|
|
367
|
+
console.log(JSON.stringify({ step: 'provisioned', ...prov, transportAfter: 'relay' }, null, 2));
|
|
368
|
+
await relay.close();
|
|
369
|
+
}
|