samagotchi 0.121.0 → 0.121.1

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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -1
@@ -66,10 +66,11 @@ files, one per machine, never in config.yml:
66
66
  `Store#add` checks what `pushManager.subscribe` gave, one message per refusal (the route answers it as 400
67
67
  `invalid_subscription`): an endpoint and both keys; https on port 443 without userinfo, at most 2048 bytes; a host in
68
68
  `Store::PUSH_SERVICES` (Apple, Google's FCM, Mozilla, `*.notify.windows.com`; a `services:` seam for specs, not
69
- config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point,
70
- `auth` 16 bytes. The same endpoint again replaces the old entry. `replaces:` (the device's id from its last
71
- subscribe, which the page keeps) drops that entry on this origin too, before the `MAX_SUBS` cap is counted, and
72
- yields it (the route logs `push_replaced`); its own endpoint is never dropped that way, and an unknown id is ignored.
69
+ config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point
70
+ on P-256 (built as `Crypto.ec_public` builds it), `auth` 16 bytes. The same endpoint again replaces the old entry.
71
+ `replaces:` (the device's id from its last subscribe, which the page keeps) drops that entry on this origin too,
72
+ before the `MAX_SUBS` cap is counted, and yields it (the route logs `push_replaced`); its own endpoint is never
73
+ dropped that way, and an unknown id is ignored.
73
74
  That is how a subscription iOS revoked silently (Apple keeps answering 201, so `:gone` never comes) leaves the store:
74
75
  the user turns the bell off and on, the page has no live subscription to delete, and the new one replaces it. Every write is a read-change-write under
75
76
  `store.lock` (chi web's request threads and the sender thread both write). A `subscriptions.json` that doesn't read
@@ -85,7 +86,9 @@ tailnet leaves the old ones stale (never sent to: a tap would open an origin tha
85
86
  `Client#deliver(sub, payload, ttl:, urgency:)` encrypts the payload's JSON, POSTs it over TLS (open 5 s, read 10 s,
86
87
  no redirects, no retry of its own) with `Content-Encoding: aes128gcm`, `TTL`, `Urgency` and the VAPID header, and
87
88
  answers a `Client::Result`: `:ok` (200/201/202), `:gone` (404/410: the device dropped it), `:bad_jwt` (403 with
88
- `BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message).
89
+ `BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message;
90
+ any other error too, so a key off the curve on disk fails that device alone, never the Notifier's flush or the test
91
+ push).
89
92
  A 429 or 5xx also carries `retry_after`: its `Retry-After` delta-seconds up to `RETRY_MAX` 60, else (missing, an
90
93
  HTTP date) `RETRY_DEFAULT` 10; nil for anything else, a connection error too (it may have been delivered). The
91
94
  transport is injectable: `spec/web/push/client_spec.rb` runs a fake push service (WEBrick, plain http through the
@@ -206,7 +209,8 @@ public URL, or `web.push.enabled: false`):
206
209
  - `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
207
210
  chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
208
211
  drops the device's earlier entry (`Store#add`).
209
- - `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off.
212
+ - `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off (`Store#remove(endpoint, origin:)`:
213
+ only a device on this origin goes, as with the other device routes).
210
214
  - `DELETE /api/push/subscriptions/ID` → 204, 404 `unknown_device` (another origin's, or not a short id); logs
211
215
  `push_removed`. The devices sheet's Remove for another device; this device's own row never uses it (its switch is
212
216
  the bell, so the page's state and the store stay one).
@@ -245,9 +249,13 @@ Screen app the bell (`notifications.js`) registers `/sw.js`, asks `GET /api/push
245
249
  and is "push to this phone" when chi web pushes: on while chi web has this device. A tap asks permission from the tap
246
250
  (iOS asks only from a user gesture), subscribes, posts it (with `replaces`: the id chi web answered last time,
247
251
  kept in localStorage as `chi_push_device`, `PUSH_DEVICE_KEY`; a storage that throws only loses the replace) and sends
248
- a test push; a tap when on unsubscribes and deletes, and forgets the id, unless there was no subscription left to
249
- delete (revoked meanwhile): then the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
250
- chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
252
+ a test push; a tap when on deletes, then unsubscribes (a DELETE that fails changes nothing local: the bell stays on
253
+ and says the error), and forgets the id, unless there was no subscription left to delete (revoked meanwhile): then
254
+ the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title
255
+ count stays. A `GET /api/push` (or `register()`) that fails at launch (chi web restarting, a phone just woken) leaves
256
+ the bell the in-tab one until the page next comes back in front (`backInFront` → `retryPush`, one at a time, no
257
+ timer); chi web answering that it doesn't push is not asked again. In a browser tab, or with chi web not pushing,
258
+ the bell is the in-tab one, unchanged (no duplicate desktop pushes).
251
259
 
252
260
  **The devices sheet** (`public/push_devices.js`, `createPushDevices` on `chip_popover.js`'s shell: a bottom sheet
253
261
  under 600 px, else a popover at the bell; made once chi web says it pushes, so a tab never gets one): in push mode
@@ -273,8 +281,11 @@ page's `byId` (`push.js openPushKeys`: `Attention.open_keys`'s rule), and sets o
273
281
  badge (`navigator.setAppBadge` / `clearAppBadge`); the bell turned off clears it. It runs once push is known, when
274
282
  the page comes back in front, and after each hub frame while visible (so answering on the Mac with the app open
275
283
  clears it at once), one at a time with one rerun. Never before the first snapshot (an empty `byId` would close
276
- everything) and never on a project's page (`createNotifications({scoped})`, app.js passes `!!scope.dir`: its
277
- `/api/events?dir=` leaves out the other projects' sessions).
284
+ everything). The device's notifications are every project's, so on a project's page (`?dir=`, or the bare page on
285
+ chi web's start project: `createNotifications({scoped})`, app.js passes `!!scope.dir`) the tidy-up goes by a stream
286
+ of every session of its own (`watchAll`, through `allEvents`: data.js `openEvents` without a dir), not the page's
287
+ `/api/events?dir=` frames, which leave out the other projects' sessions. That stream is open only with the bell on
288
+ (turning it off closes it), one at a time, and one that ended is opened again when the page next comes back in front.
278
289
 
279
290
  Accepted staleness: an approval answered elsewhere keeps its banner until the app opens (the worker's cleanup on the next push doesn't work on iOS yet: see iOS facts)
280
291
  (or a swipe), and the icon badge may be too high until then; a show already handed to Apple just before its answer
data/docs/plugins.md CHANGED
@@ -587,7 +587,8 @@ config or bundle toolkit) with `add` declared and `remove` left out, and
587
587
  inherits the base's, `""` clears it; at most 2000 characters, and one that
588
588
  isn't a String or is longer is a load error). `groups` selects the source
589
589
  groups the toolkit reaches (names or globs, sorted and deduplicated; nil
590
- inherits the base's, `[]` reaches none, and `:all` reaches every group).
590
+ inherits the base's, `[]` reaches none, and `:all` reaches every group; anything
591
+ else, a map say, is a load error).
591
592
  Groups match against the tool index's load-time names. Users pick
592
593
  it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
593
594
  and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
@@ -597,7 +598,7 @@ tool (full) until it comes back, with a warning at its first turn. See
597
598
  [Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
598
599
  chi refuses the keyword, a load error: a bundle that passes it sets
599
600
  `requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
600
- too, so a bundle passing it sets `requires_chi: ">= 0.121.0"`.
601
+ too, so a bundle passing it sets `requires_chi: ">= 0.121.0"` (`chi bundle check` warns on a lower one).
601
602
 
602
603
  ```ruby
603
604
  chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
@@ -1014,6 +1015,9 @@ ctx.sessions.review(subjects: ["docs/plan.md"], seed: handoff) # => a review se
1014
1015
  `/title` of its own (see [Session titles](sessions.md#session-titles)).
1015
1016
  - An image a message names is copied into the child. One whose file is
1016
1017
  gone is dropped, with `[image x.png was not copied]` in its message.
1018
+ - A child chi won't start raises `Error` with chi's reason: the session's
1019
+ model is gone from the config, or its toolkit is a config kit with an
1020
+ invalid `groups:` (`toolkit.kits`, `Toolkit.check_new!`).
1017
1021
  - `review(subjects: [], seed: nil, recap_wait: 0)` starts a review session of
1018
1022
  this one, as `chi review --from` does (see [Review sessions](sessions.md#review-sessions)):
1019
1023
  a new session in this session's folder, on its model and setup, seeded with
data/docs/sessions.md CHANGED
@@ -377,3 +377,18 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
377
377
  - **A child asks before it changes anything outside its folder** (the work tree top of the folder it started in): a write or edit there, mutating git in another checkout, a command that isn't read-only run from outside it or naming the main checkout or a sibling worktree. It's a core guardrail, on in every mode and without the guardrails bundle, and answered once or for the child's session ([Guardrails](guardrails.md#a-delegate-childs-boundary)); the approval relays to the parent's user as any other.
378
378
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
379
379
  - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions`, `/api/sessions/:id` and the web hub's `session` frames carry it and `delegate` (a fork is `false`; the web parent's children chip counts delegates only); `SessionManager.children_of` lists a parent's children.
380
+ ## Restart fallback
381
+
382
+ If `chi sessions restart ID` cannot ask its worker (the Bridge is unreachable
383
+ or refuses its secret, the worker speaks a newer protocol, or its start is
384
+ stuck), chi sends TERM without writing the stop marker, waits up to
385
+ `SessionManager::FALLBACK_STOP_WAIT` (40 s: the worker writes its recap on
386
+ leave) for the owner lock, and resumes the session; a worker that doesn't let
387
+ go in time is reported as "didn't take the restart" and nothing is resumed. A
388
+ worker that took the lock meanwhile (the old one handed over after all) is
389
+ not touched. It refuses first if disk shows a running turn, pending
390
+ question or approval, queued input, or a running background task started by
391
+ the session; the message directs the user to `chi sessions stop ID`, then any prompt.
392
+ Reminders, a running `/btw`, and other in-memory worker state cannot be
393
+ checked and may be lost in this fallback. The web restart route behaves the
394
+ same way.
data/docs/testing.md CHANGED
@@ -163,9 +163,44 @@ A session's Bridge answers only a request that names its secret (`X-Chi-Bridge`,
163
163
  see [architecture.md](architecture.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
164
164
  socket) names it too: `include BridgeAuth` (spec/support/bridge_auth.rb, required by the spec that needs it) gives
165
165
  `bridge_auth(session_dir_or_sidecar)`, the header as a Hash, and `bridge_auth_line(...)` for a raw request; or read
166
- `port` and `secret` from the sidecar in one go, as `BridgeClient.discover` does. A client made with `BridgeClient.new`
166
+ `port` and `secret` from the sidecar in one go, as `WorkerLink.client` does. A client made with `BridgeClient.new`
167
167
  takes `secret:` (nil for a fake Bridge that doesn't check it).
168
168
 
169
+ ### Support files
170
+
171
+ `spec/spec_helper.rb` loads the suite-wide helpers (marked *helper*: every example group gets them); a spec that needs
172
+ any other file requires it, so a spec file run alone still finds its requires. Each file's header says more.
173
+
174
+ | File | What it gives |
175
+ |---|---|
176
+ | `bounded_capture.rb` | `BoundedCapture.capture3`: `Open3.capture3` with a deadline, in its own process group killed on expiry |
177
+ | `bridge_auth.rb` | `BridgeAuth`: the Bridge secret's header for a hand-written request (above) |
178
+ | `chi_cli.rb` | *helper*: `run_chi` (bin/chi in a child process), `fork_chi` (in a fork: no Ruby boot), `isolated_chi_env` |
179
+ | `effective_model_configs.rb` | `EffectiveModelConfigs`: one config.yml holding every host the effective-model characterization pins, and its goldens |
180
+ | `env.rb` | *helper*: `with_env` sets ENV for a block and puts it back (nil unsets) |
181
+ | `failing_saves.rb` | shared context "failing session saves": `Session#save` raising ENOSPC while `saves_fail`, and the worker's `save_failed` records |
182
+ | `fake_chat_adapter.rb` | `FakeChatAdapter`: an `LLM::OpenAIChat` stand-in answering `#chat` with queued steps, recording each request |
183
+ | `fake_executables.rb` | `FakeExecutables`: fake commands (`gh`, `git`) for a spec's PATH, symlinks to one launcher (macOS's first-exec check is paid once) |
184
+ | `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
185
+ | `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
186
+ | `git_repo.rb` | *helper*: `git_repo`, a temp folder with `git init`, removed after the example |
187
+ | `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
188
+ | `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
189
+ | `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
190
+ | `recording_surface.rb` | `RecordingSurface`: a `TerminalUI::Surface` that records commits and slots instead of drawing |
191
+ | `remote_backend.rb` | `RemoteBackend`: `SessionAccess::Remote` against chi web's Rack app in this process (no socket, LAN token gate) |
192
+ | `reply_wait_looks.rb` | `ReplyWaitLooks`: counts a `ReplyWait`'s polls, so a step waits for the looks instead of a delay |
193
+ | `stamps.rb` | *helper*: `next_stamp`, distinct ordered times for the lib's `now:` seams; the pinned history clock |
194
+ | `task_dirs.rb` | `with_task_dir`: a tasks' folder of its own, every task stopped and its recorders joined after |
195
+ | `test_kernel.rb` | `TestKernel`: `test_kernel` (a real `KernelLoop` to stub `#run` on) and `test_client` for Engine specs |
196
+ | `thinking_off.rb` | shared context "thinking off": `SAMAGOTCHI_THINKING_LEVEL=off` for the example |
197
+ | `virtual_terminal.rb` | `VirtualTerminal`: just enough VT100 to check what a Screen leaves on the terminal |
198
+ | `waiting.rb` | *helper*: `SpecWaiting` (`wait_for`, `wait_until`, `join!`, `pop!`, …), the test kit's helpers ([Waiting and time](#waiting-and-time)) |
199
+ | `web_push_receiver.rb` | `WebPushReceiver`: a device's key pair and auth secret, decrypting what chi web pushed (RFC 8291) |
200
+ | `web_server.rb` | `WebServer`: chi web's `Web::App` served for real on a free port in a thread, for specs a Rack mock can't serve |
201
+ | `worker_links.rb` | `WorkerLinks`: `live_link(sidecar)` / `none_link`, the `WorkerLink` outcomes a stub of `WorkerLink.resolve` / `.await` (or chi web's `#worker_link`) returns |
202
+ | `worker_loop_rounds.rb` | `WorkerLoopRounds`: counts a worker's idle loop passes, for "nothing happens" checks |
203
+
169
204
  ## Bundle specs: the test kit
170
205
 
171
206
  A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
@@ -357,3 +392,8 @@ kept with the build id `/status` shows (`identity.cache.build_id`); when that ch
357
392
  the version and record it again. This step goes once a Splash release carries its version in `/status`.
358
393
 
359
394
  The code is `script/deps/deps_check.rb`; its spec stubs every request and command (`spec/deps_check_spec.rb`).
395
+
396
+ `script/dead-code` (a tech-debt audit round runs it too) lists the methods defined under lib/ that nothing in the repo
397
+ names (Prism; `be_foo`/`have_foo` count for `foo?`/`has_foo?`), as `path:line name [notes]` with a count; a note says
398
+ what may explain a row (a doc, a name built at run time, a framework hook). Advisory: it always exits 0, CI doesn't
399
+ run it. Its spec is `spec/script/dead_code_spec.rb`.
@@ -196,7 +196,7 @@ module Samagotchi
196
196
  end
197
197
  return failure(worker_gone(id)) unless reply
198
198
 
199
- return failure(BridgeSecret::REFUSED) if reply.secret_refused?
199
+ return failure(BridgeSecret.refused(id, via: @access.invocation)) if reply.secret_refused?
200
200
 
201
201
  case reply.status
202
202
  when 200 then nil
@@ -32,6 +32,7 @@ require_relative "log"
32
32
  require_relative "proxy_trace"
33
33
  require_relative "version"
34
34
  require_relative "worker_sidecar"
35
+ require_relative "worker_link"
35
36
  require_relative "relay_verifier"
36
37
  require_relative "relay_watcher"
37
38
  require_relative "reply_wait"
@@ -75,9 +76,6 @@ module Samagotchi
75
76
  SECRET_HEADER = BridgeSecret::HEADER.downcase
76
77
  # Bytes of randomness in a Bridge's secret (hex in bridge.json: twice as many characters).
77
78
  SECRET_BYTES = 32
78
- # What a request without this Bridge's secret, or with another one, is told (403 bridge_secret).
79
- SECRET_REFUSED = BridgeSecret::REFUSED
80
-
81
79
  # @param engine [Samagotchi::Engine] the owning engine (must already live
82
80
  # in this process)
83
81
  # @param state_dir [String] the session state directory root
@@ -484,7 +482,7 @@ module Samagotchi
484
482
  break
485
483
  elsif !secret_ok?(headers)
486
484
  note_refused(:secret, "secret_refused", request[:path])
487
- write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: SECRET_REFUSED })
485
+ write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: BridgeSecret.refused(@session_id) })
488
486
  break
489
487
  # From here on a client of this worker (it named the secret): only its
490
488
  # requests keep the worker from its idle exit, never a refused one's
@@ -1500,17 +1498,19 @@ module Samagotchi
1500
1498
  # secret: what every request must name (#secret_ok?).
1501
1499
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
1502
1500
  version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
1503
- epoch: @epoch, secret: @secret).write(session_dir)
1501
+ epoch: @epoch, secret: @secret, pid: Process.pid,
1502
+ protocol: WorkerLink::PROTOCOL).write(session_dir)
1504
1503
  rescue StandardError => e
1505
1504
  Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
1506
1505
  end
1507
1506
 
1508
- # Only while it still names this bridge: a racing worker for the same
1509
- # session may have written its own since.
1507
+ # Only while it still carries this bridge's epoch: a racing worker for the
1508
+ # same session may have written its own since (a file without an epoch
1509
+ # is only ever an older worker's).
1510
1510
  def remove_sidecar
1511
1511
  return unless @server
1512
1512
 
1513
- File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.port == @port
1513
+ File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.epoch == @epoch.to_s
1514
1514
  rescue StandardError
1515
1515
  nil
1516
1516
  end
@@ -158,16 +158,18 @@ module Samagotchi
158
158
 
159
159
  # One connection: :events when it yielded any, else why it failed.
160
160
  def read_connection
161
- sock, status, headers = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
162
- return :unreachable unless sock
163
-
164
- unless attach(sock)
165
- sock.close rescue nil
166
- return :closed
161
+ sock = nil
162
+ outcome = @client.open_stream(query: @query, last_event_id: @last_event_id, header_timeout: HEADER_TIMEOUT,
163
+ attempts: 1) do |opened_sock|
164
+ sock = opened_sock if attach(opened_sock)
167
165
  end
168
- return :unknown_session if status == 404
169
- return :secret_refused if status == 403 && BridgeSecret.refused?(status, refusal_body(sock, headers))
170
- return :unreachable unless status == 200
166
+ return :closed if closed?
167
+ return :unreachable if outcome.is_a?(StreamOpen::Unreachable)
168
+ return :unknown_session if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :unknown_session
169
+ return :secret_refused if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :bridge_secret
170
+ return :unreachable unless outcome.is_a?(StreamOpen::Opened)
171
+
172
+ sock = outcome.sock
171
173
 
172
174
  # Back as soon as the stream answers: a replay may bring nothing
173
175
  # (the session was quiet), and the next frame could be minutes away.
@@ -194,24 +196,6 @@ module Samagotchi
194
196
  detach(sock)
195
197
  end
196
198
 
197
- # A refusal's JSON body: its Content-Length's worth, else up to the
198
- # Bridge's close, within HEADER_TIMEOUT.
199
- def refusal_body(sock, headers)
200
- body = +""
201
- length = headers.to_h["content-length"]&.to_i
202
- limit = length&.between?(0, 16_384) ? length : 16_384
203
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HEADER_TIMEOUT
204
- while body.bytesize < limit
205
- left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
- break unless left.positive? && sock.wait_readable(left)
207
-
208
- body << sock.readpartial(limit - body.bytesize)
209
- end
210
- body
211
- rescue IOError, SystemCallError
212
- body
213
- end
214
-
215
199
  # Publish the socket for #close; false when already closed.
216
200
  def attach(sock)
217
201
  @mutex.synchronize do
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../bridge_secret"
5
+
6
+ module Samagotchi
7
+ class BridgeClient
8
+ # The result of opening an SSE connection. Only Opened owns a socket the
9
+ # caller may read; refused sockets are consumed for their detail and closed.
10
+ module StreamOpen
11
+ Opened = Data.define(:sock, :status, :headers)
12
+ Refused = Data.define(:reason, :detail, :status)
13
+ Unreachable = Data.define(:attempts)
14
+
15
+ BODY_LIMIT = 16_384
16
+ BODY_TIMEOUT = 5.0
17
+
18
+ module_function
19
+
20
+ # The one place a stream's response status is interpreted. Through chi
21
+ # web, its mapped bridge-secret and not-live responses have their own
22
+ # status codes (502 and 503).
23
+ def classify(sock, status, headers)
24
+ return Unreachable.new(attempts: 0) unless sock
25
+ return Opened.new(sock: sock, status: 200, headers: headers) if status.to_i == 200
26
+ return Unreachable.new(attempts: 1) if status.to_i == 503
27
+
28
+ body = refusal_body(sock, headers)
29
+ json = parse(body)
30
+ detail = json&.[]("detail") || (body unless body.empty?)
31
+ code = status.to_i
32
+ error = json&.[]("error")
33
+ outcome = if code == 404
34
+ Refused.new(reason: :unknown_session, detail: detail, status: code)
35
+ elsif BridgeSecret.refused?(code, json || body)
36
+ Refused.new(reason: :bridge_secret, detail: detail, status: code)
37
+ elsif code == 403 && error == "proxied"
38
+ Refused.new(reason: :proxied, detail: detail, status: code)
39
+ elsif code == 403 && error == "cross_origin"
40
+ Refused.new(reason: :cross_origin, detail: detail, status: code)
41
+ elsif code == 502 && error == BridgeSecret::ERROR
42
+ Refused.new(reason: :bridge_secret, detail: detail, status: code)
43
+ else
44
+ Refused.new(reason: :other, detail: detail, status: code)
45
+ end
46
+ outcome
47
+ ensure
48
+ sock&.close unless (status || 0).to_i == 200
49
+ end
50
+
51
+ # A refusal body is bounded and interruptible by closing its socket.
52
+ # Content-Length replies stop at their declared size; older Bridges
53
+ # without it are read through EOF.
54
+ def refusal_body(sock, headers)
55
+ length = headers.to_h["content-length"]&.to_i
56
+ limit = length&.between?(0, BODY_LIMIT) ? length : BODY_LIMIT
57
+ body = +""
58
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BODY_TIMEOUT
59
+ while body.bytesize < limit
60
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ break unless left.positive? && sock.wait_readable(left)
62
+
63
+ body << sock.readpartial(limit - body.bytesize)
64
+ end
65
+ body.force_encoding(Encoding::UTF_8).scrub
66
+ rescue IOError, SystemCallError
67
+ body.force_encoding(Encoding::UTF_8).scrub
68
+ end
69
+
70
+ def parse(body)
71
+ json = JSON.parse(body)
72
+ json if json.is_a?(Hash)
73
+ rescue JSON::ParserError
74
+ nil
75
+ end
76
+ private_class_method :parse
77
+ end
78
+ end
79
+ end
@@ -32,8 +32,14 @@ module Samagotchi
32
32
  # @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
33
33
  # at the body, the HTTP status and the response headers (names in
34
34
  # lower case), or nil when the server can't be reached
35
+ # @raise [Errno::ETIMEDOUT] a connected server didn't finish its headers
35
36
  def open(host:, port:, path:, last_event_id: nil, headers: {}, timeout: nil)
36
- sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
37
+ begin
38
+ sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
39
+ rescue SystemCallError, SocketError, IOError
40
+ return nil
41
+ end
42
+
37
43
  sock.write(request(host: host, port: port, path: path, last_event_id: last_event_id, headers: headers))
38
44
  deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
39
45
  status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
@@ -47,7 +53,7 @@ module Samagotchi
47
53
  [sock, status, received]
48
54
  rescue SystemCallError, SocketError, IOError
49
55
  sock&.close rescue nil
50
- nil
56
+ raise
51
57
  end
52
58
 
53
59
  # The request's bytes.
@@ -8,6 +8,7 @@ require_relative "worker_sidecar"
8
8
  require_relative "bridge_secret"
9
9
  require_relative "delivery"
10
10
  require_relative "chi_invocation"
11
+ require_relative "bridge_client/stream_open"
11
12
  require_relative "bridge_client/sse_parser"
12
13
  require_relative "bridge_client/event_stream"
13
14
  require_relative "bridge_client/stream_request"
@@ -15,8 +16,8 @@ require_relative "bridge_client/stream_request"
15
16
  module Samagotchi
16
17
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
17
18
  # each SessionManager worker runs for its Engine (see Bridge). Discovery goes
18
- # through the worker's `bridge.json` sidecar (WorkerSidecar) in the session
19
- # directory.
19
+ # through WorkerLink (the worker's `bridge.json` sidecar, WorkerSidecar, in
20
+ # the session directory); .for_sidecar makes the client.
20
21
  #
21
22
  # Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
22
23
  # exactly what the Bridge's minimal server expects.
@@ -27,7 +28,7 @@ module Samagotchi
27
28
  # The request header that carries the Bridge's secret (Bridge#secret_ok?).
28
29
  SECRET_HEADER = BridgeSecret::HEADER
29
30
  STREAM_CONNECT_ATTEMPTS = 3
30
- # Seconds #stream waits for the Bridge's response headers, per attempt:
31
+ # Seconds #open_stream waits for the Bridge's response headers:
31
32
  # a worker that took the connection and froze must not hold the web's
32
33
  # proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
33
34
  STREAM_HEADER_TIMEOUT = 5.0
@@ -49,7 +50,7 @@ module Samagotchi
49
50
  Response = Struct.new(:status, :body, keyword_init: true) do
50
51
  def ok? = status == 200
51
52
 
52
- # The Bridge refused it for want of its secret (BridgeSecret::REFUSED says what to do).
53
+ # The Bridge refused it for want of its secret (BridgeSecret.refused says what to do).
53
54
  def secret_refused? = BridgeSecret.refused?(status, body)
54
55
 
55
56
  # @return [Hash, nil] the body parsed as JSON, or nil when it is not
@@ -75,14 +76,6 @@ module Samagotchi
75
76
  "this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
76
77
  end
77
78
 
78
- # The live Bridge's sidecar in +session_dir+, or nil. A sidecar whose
79
- # port refuses a quick connect is stale (its worker died) and is removed.
80
- # @param session_dir [String]
81
- # @return [WorkerSidecar, nil]
82
- def self.live_sidecar(session_dir, host: HOST)
83
- WorkerSidecar.live(session_dir, unlink: true, host: host)
84
- end
85
-
86
79
  # The one way a client is made for a worker: its port and its secret
87
80
  # come from the same sidecar read, so a respawned worker (a new port, a
88
81
  # new secret) is reached by a client made after it, never by hand.
@@ -94,24 +87,11 @@ module Samagotchi
94
87
  new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
95
88
  end
96
89
 
97
- # @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
98
- def self.discover(session_id, session_dir:, host: HOST, read_timeout: READ_TIMEOUT)
99
- for_sidecar(session_id, live_sidecar(session_dir, host: host), host: host, read_timeout: read_timeout)
100
- end
101
-
102
90
  # How often a wait for a new worker's Bridge looks: a missing sidecar is
103
91
  # one stat, and a 0.1 s step cost a create up to 0.1 s (the web's
104
92
  # create landed on 0.53/0.63/0.73 s).
105
93
  SPAWN_POLL_INTERVAL = 0.02
106
94
 
107
- # Wait for a freshly spawned worker to publish its Bridge.
108
- # @param timeout [Float] seconds
109
- # @return [BridgeClient, nil] nil when no live sidecar appeared in time
110
- def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
111
- sidecar = poll(timeout, interval: SPAWN_POLL_INTERVAL) { live_sidecar(session_dir, host: host) }
112
- for_sidecar(session_id, sidecar, host: host)
113
- end
114
-
115
95
  # Call the block every +interval+ seconds until it returns a truthy value
116
96
  # or +timeout+ seconds have passed (it is always called at least once).
117
97
  # @return [Object, nil] the block's first truthy value
@@ -314,22 +294,26 @@ module Samagotchi
314
294
  # @return [Array(Integer, Object)] the status and the parsed JSON body
315
295
  # (nil when it isn't JSON); [nil, nil] when no reply came
316
296
  def get(path)
297
+ response = get_response(path)
298
+ [response.status, response.json]
299
+ rescue StandardError
300
+ [nil, nil]
301
+ end
302
+
303
+ # GET /session/:id/<path>, preserving the raw response and raising on I/O.
304
+ # @return [Response]
305
+ def get_response(path)
317
306
  sock = TCPSocket.new(@host, @port)
318
307
  sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Connection: close\r\n\r\n")
319
308
  response = read_reply(sock, path)
320
- sock.close rescue nil
321
309
  # A 500 whose text has "200" in it is not a 200.
322
310
  status = response && response[STATUS_LINE, 1]
323
- return [nil, nil] unless status
311
+ raise IOError, "bridge #{path}: invalid response status" unless status
324
312
 
325
313
  body = response.split("\r\n\r\n", 2)[1] || ""
326
- [status.to_i, begin
327
- JSON.parse(body)
328
- rescue JSON::ParserError
329
- nil
330
- end]
331
- rescue StandardError
332
- [nil, nil]
314
+ Response.new(status: status.to_i, body: body)
315
+ ensure
316
+ sock&.close rescue nil
333
317
  end
334
318
 
335
319
  # Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
@@ -342,46 +326,49 @@ module Samagotchi
342
326
  nil
343
327
  end
344
328
 
345
- # GET /session/:id/stream: yield the raw SSE body bytes as they arrive
346
- # until the Bridge closes the stream.
347
- # @param query [String] "" or "?from_seq=N"
348
- # @param last_event_id [String, nil] reconnect cursor; the Bridge prefers it
349
- # over ?from_seq
350
- # @param running [#call, nil] asked before every read, and every
351
- # STREAM_POLL seconds of silence: the stream ends once it returns
352
- # false. A quiet stream is never cut otherwise.
329
+ # Open GET /session/:id/stream and classify the response before the
330
+ # caller answers its own request.
353
331
  # @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
354
- def stream(query: "", last_event_id: nil, running: nil, header_timeout: STREAM_HEADER_TIMEOUT)
355
- sock = nil
356
- # Absorb the probe→connect race around a resumed worker's bridge:
357
- # the sidecar probe can succeed a moment before the worker dies (or
358
- # the bridge socket briefly refuses). A few quick bounded retries
359
- # avoid the silent empty-200 that EventSource would otherwise keep
360
- # re-opening.
361
- status = nil
362
- STREAM_CONNECT_ATTEMPTS.times do |attempt|
363
- sock, status = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
364
- break if sock
365
-
366
- sleep(0.15 * (attempt + 1))
367
- end
368
- return unless sock
369
-
370
- # A refusal (a 403 such as bridge_secret) is no event stream: its JSON
371
- # isn't passed on as one.
372
- if status == 403
373
- sock.close rescue nil
374
- return
332
+ # @param attempts [Integer] retries for connection failures only
333
+ # @yield [sock] called after response headers arrive, before classifying a
334
+ # refusal; EventStream uses it to publish the socket for #close
335
+ # @return [StreamOpen::Opened, StreamOpen::Refused, StreamOpen::Unreachable]
336
+ def open_stream(query: "", last_event_id: nil, header_timeout: STREAM_HEADER_TIMEOUT,
337
+ attempts: STREAM_CONNECT_ATTEMPTS)
338
+ count = 0
339
+ while count < attempts
340
+ count += 1
341
+ connection = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
342
+ if connection
343
+ sock, status, headers = connection
344
+ unless !block_given? || yield(sock)
345
+ sock.close rescue nil
346
+ return StreamOpen::Unreachable.new(attempts: count)
347
+ end
348
+ return StreamOpen.classify(sock, status, headers)
349
+ end
350
+ sleep(0.15 * count) if count < attempts
375
351
  end
352
+ StreamOpen::Unreachable.new(attempts: count)
353
+ rescue Errno::ETIMEDOUT
354
+ StreamOpen::Unreachable.new(attempts: [count, 1].max)
355
+ rescue SystemCallError, SocketError, IOError
356
+ StreamOpen::Unreachable.new(attempts: [count, 1].max)
357
+ end
376
358
 
359
+ # Read a successful stream opened by #open_stream.
360
+ # @param opened [StreamOpen::Opened]
361
+ # @param running [#call, nil] checked before every read and every
362
+ # STREAM_POLL seconds of silence
363
+ def read_stream(opened, running: nil)
364
+ sock = opened.sock
377
365
  begin
378
366
  loop do
379
367
  if running
380
368
  break unless running.call
381
369
  next unless sock.wait_readable(STREAM_POLL)
382
370
  end
383
- chunk = sock.readpartial(4096)
384
- yield chunk
371
+ yield sock.readpartial(4096)
385
372
  rescue IOError, Errno::ECONNRESET, Errno::ECONNREFUSED # EOFError is an IOError
386
373
  break
387
374
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "chi_invocation"
4
5
 
5
6
  module Samagotchi
6
7
  # A session Bridge's secret, as both sides name it: the request header
@@ -12,16 +13,17 @@ module Samagotchi
12
13
  HEADER = "X-Chi-Bridge"
13
14
  # The 403's error.
14
15
  ERROR = "bridge_secret"
15
- # What a request without this Bridge's secret, or with another one, is
16
- # told, and what a client shows when it gets that: the asker comes from
17
- # a chi that reads no secret, a chi web, an attached terminal or a
18
- # session worker (a delegate's parent) started before chi was updated.
19
- REFUSED = "the request didn't name this session worker's secret: a chi web, an attached terminal or a session " \
20
- "worker started before chi was updated can't reach it; restart chi web, re-attach terminals " \
21
- "(chi --attach ID) and restart the older session workers (chi sessions restart ID...)"
22
16
 
23
17
  module_function
24
18
 
19
+ # What every client shows when a worker refuses its secret.
20
+ def refused(session_id, via: ChiInvocation::HERE)
21
+ web = via.remote? ? via.web : "chi web"
22
+ "session #{session_id}'s worker refused this chi's request (it didn't name its secret): a chi web or " \
23
+ "terminal started before chi was updated can't reach it; restart #{web}, re-attach with " \
24
+ "#{via.attach(session_id)}, or restart the worker with #{via.command("sessions restart #{session_id}")}"
25
+ end
26
+
25
27
  # Whether a Bridge reply (+status+, raw +body+) is this refusal.
26
28
  def refused?(status, body)
27
29
  return false unless status.to_i == 403