samagotchi 0.120.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +34 -11
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +6 -2
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +28 -8
- data/docs/sessions.md +15 -0
- data/docs/testing.md +44 -2
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +21 -7
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +117 -27
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +11 -6
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +4 -2
- data/lib/samagotchi/web/public/participant.js +34 -11
- data/lib/samagotchi/web/public/participants_panel.js +49 -14
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +24 -4
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/session_access_routes.rb +25 -17
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +4 -1
|
@@ -79,11 +79,13 @@ right-hand column happens.
|
|
|
79
79
|
`Toolkit::Resolved` the Registry holds, so a switch that changes only the text is a toolkit change
|
|
80
80
|
(`apply_toolkit!` → `reset!`). `full` and kits without one send the bytes they sent before.
|
|
81
81
|
- find_tools' description is a function of the toolkit and the source groups **at load**: `ToolIndex#declare`
|
|
82
|
-
snapshots
|
|
83
|
-
`ToolkitBinding#apply!`)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
snapshots source group names and description lines (name, summary, callable count). `ToolIndex#refresh!` (from
|
|
83
|
+
`ToolkitBinding#apply!`) filters that snapshot by the new toolkit and rebuilds the `chi` line from the built-ins
|
|
84
|
+
it reaches; it never consults live group names. The same group filter drives searches, deferred MCP tools and
|
|
85
|
+
whether `find_tools`/`call_tool` are declared (`Registry#index_needed?`). An MCP server that finishes booting
|
|
86
|
+
between load and the first turn changes search answers only, so two sessions on the same toolkit with the same
|
|
87
|
+
MCP cache send the same bytes. Kits that omit `groups:` keep their previous request bytes; switching only
|
|
88
|
+
groups changes the prompt key and asks `/toolkit` to confirm the cache cost once the session has history.
|
|
87
89
|
- A worker's `execute` and `task_create` descriptions carry the finish wake's sentence ("if you end your turn before
|
|
88
90
|
it finishes, chi tells you when it does"; `Engine.new(task_finish_notes:)`, set once as the worker builds its
|
|
89
91
|
Engine); the `--no-shared` REPL's, and a worker's under `session.task_wakes: off`, don't. A session that moves
|
|
@@ -240,4 +242,3 @@ is prefilled once. The native and the chat loop use the same plan.
|
|
|
240
242
|
`cache_prompt: true` on every native request.
|
|
241
243
|
- **OpenRouter / Anthropic:** the default cache TTL is 5 minutes (`cache.ttl: 1h` for an hour, see above).
|
|
242
244
|
Provider routing (which upstream serves a request) also decides whether a cache is there to hit.
|
|
243
|
-
|
|
@@ -289,11 +289,11 @@ bin/chi ── RunOptions.parse ── a remote, run flags ──> RemoteAttachC
|
|
|
289
289
|
### The plumbing
|
|
290
290
|
|
|
291
291
|
**chi web's routes** (tabled in [architecture.md](../architecture.md)): `POST …/wake` (`Local#wake`: the live worker
|
|
292
|
-
as is, else `SessionManager.resume_session` and `
|
|
292
|
+
as is, else `SessionManager.resume_session` and `WorkerLink.await`, at most `WAKE_MAX_WAIT` 10 s, the attach's
|
|
293
293
|
`BRIDGE_WAIT` here, after waiting for a worker still leaving to let go of its lock, `OwnerLock.await_release`: its
|
|
294
294
|
recap on leave's timeout plus 5 s; a leftover scratch session is refused in `LaunchMode::SCRATCH_LEFTOVER`'s words, a late Bridge
|
|
295
|
-
in `LaunchMode::BRIDGE_LATE`'s) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
|
|
296
|
-
is leaving, `WorkerState#leaving
|
|
295
|
+
in `LaunchMode::BRIDGE_LATE`'s; a worker on a newer sidecar protocol is refused, 409 `worker_newer`, never resumed over) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
|
|
296
|
+
is leaving, `WorkerState#leaving?`, `stuck` for a worker that never published its Bridge, `skew` for a newer protocol; it never starts one; it also names that machine's `default_model`), both answering the worker as `{epoch, started_at, version,
|
|
297
297
|
features}`; `POST …/exit` (the worker's exit, `client_id` passed on only when it is a `tui:` one), `POST …/recap`,
|
|
298
298
|
`GET …/stats` (the stats, else an older worker's state metrics), `GET …/children/counts` (`ChildrenStatus.counts`)
|
|
299
299
|
and `GET …/turns/:turn_id/records` (`SessionMetrics`' saved tool records and turn record), every refusal with
|
|
@@ -309,7 +309,7 @@ builtin the worker lacks names it (`command`). `/api/info`'s feature `remote_att
|
|
|
309
309
|
**`SessionAccess::WebBridgeClient`** (`session_access/web_bridge_client.rb`, built by `Remote#attach_client(id,
|
|
310
310
|
worker:)` from a wake's or worker read's `worker`): `post_turn` (`no_interrupt`, `history: false`), `post_command`,
|
|
311
311
|
`answer`, `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `stats`, `children_counts`,
|
|
312
|
-
`turn_records`, `follow`, `
|
|
312
|
+
`turn_records`, `follow`, `open_stream`, `session_id`, `worker_key`, `port` (nil). One-shots go through
|
|
313
313
|
`Remote#route_request` (the server check, the auth and missing-route mapping), a new connection each, with a 45 s
|
|
314
314
|
read timeout (chi web's 30 s Bridge read plus its wait for a waking Bridge; 75 s for answer and dismiss, which chi web
|
|
315
315
|
sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the children counts and a turn's records, L9). Every reply goes through **one status mapper**,
|
|
@@ -318,6 +318,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
|
|
|
318
318
|
| chi web's answer | What the loop gets |
|
|
319
319
|
|---|---|
|
|
320
320
|
| 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`NO_WORKER`) |
|
|
321
|
+
| 502 `bridge_secret` (stream) | `Refused(:bridge_secret)`; `EventStream` ends with `stream_closed bridge_secret` |
|
|
321
322
|
| 504 (any) | `Response(408, {error: deadline_passed})`: the loop's late line |
|
|
322
323
|
| 501 with `command` (`/command`) | `Response(400, {error: unknown_command, command})`: the loop names the command |
|
|
323
324
|
| any other 501 | `Response(404, {error: not_found})`: the stale-worker line |
|
|
@@ -326,7 +327,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
|
|
|
326
327
|
Every `RemoteFailed` (a transport failure, `RemoteTooOld`, a 401 or a 403 `forbidden`) becomes `Unreachable` with its
|
|
327
328
|
message, so a loop that rescues `IOError` around Bridge calls never crashes on one mid-session.
|
|
328
329
|
|
|
329
|
-
**The stream.** `
|
|
330
|
+
**The stream.** `open_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
|
|
330
331
|
GET a local stream writes, with `Authorization: Bearer` and `Last-Event-ID`; `Host` is the entry's host and port).
|
|
331
332
|
An IPv6 entry (`http://[::1]:4567`) connects on the bare address (`URI#hostname`, as `HTTPTransport` does) and keeps
|
|
332
333
|
the brackets in `Host`.
|
|
@@ -335,7 +336,9 @@ SYN isn't waited on for the OS's minutes on every reconnect.
|
|
|
335
336
|
chi web proxies the Bridge's bytes unchanged (under WEBrick a hijacked body is raw, `Connection: close`). No chunked
|
|
336
337
|
decoding in v1: a `Transfer-Encoding: chunked` reply (a proxy in between) and an https entry are logged
|
|
337
338
|
(`stream_refused`, `chunked_unsupported` / `tls_unsupported`) and read as unreachable; decoding and TLS plug into
|
|
338
|
-
`StreamRequest`. `
|
|
339
|
+
`StreamRequest`. `WebBridgeClient#open_stream` returns typed outcomes: chi web's 502 `bridge_secret` is a refusal
|
|
340
|
+
(`stream_closed bridge_secret`), 503 is unreachable and retried with rediscovery, and 404 is an unknown-session
|
|
341
|
+
refusal. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
|
|
339
342
|
passes none):
|
|
340
343
|
- `idle_timeout:` 40 s (`STREAM_IDLE_TIMEOUT`: two of the Bridge's 15 s pings plus margin): no byte for that long is a
|
|
341
344
|
half-open connection (Wi-Fi gone, a sleeping laptop), reconnected with the cursor.
|
|
@@ -343,8 +346,9 @@ passes none):
|
|
|
343
346
|
`worker_changed {"port" => nil, "epoch" => new}` comes before it (the `reset` the new Bridge sends), and the
|
|
344
347
|
client's `worker_key` is updated in place. A restart reaches the stream this way because `Bridge#stop` removes its
|
|
345
348
|
sidecar before it ends the open streams: the reconnect finds no sidecar, chi web waits for the next worker's
|
|
346
|
-
(`
|
|
347
|
-
|
|
349
|
+
(`await_worker_link`) and proxies the cursor to it. A sidecar still naming a dead port now gets 503 `not_live`;
|
|
350
|
+
a secret refusal gets 502 `bridge_secret`, and no refusal is sent as SSE frames. The stream moves by rediscovery
|
|
351
|
+
(`worker_changed`, then a `snapshot`).
|
|
348
352
|
- `announce_retries:` `stream_retrying` once a reconnect fails and rediscovery neither moved the stream nor ended it
|
|
349
353
|
(a stop prints its stopped line alone), `stream_resumed` once a stream answers again (a replay may bring no frame).
|
|
350
354
|
|
|
@@ -421,9 +425,9 @@ the closing line reads `stopped` as `unreachable`, the words it always had.
|
|
|
421
425
|
TUI (`tui:PID` otherwise). `report_unanswered` takes the link's `strategy:` and `via:`.
|
|
422
426
|
- The client the loop and `BridgeClient::EventStream` use: `follow`, `post_turn`, `post_command`, `answer`,
|
|
423
427
|
`dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `session_id`, and for the stream
|
|
424
|
-
`
|
|
425
|
-
a Bridge's is its port). `
|
|
426
|
-
and reads its headers, for a stream through chi web too.
|
|
428
|
+
`open_stream(query:, last_event_id:, header_timeout:, attempts:)` and `worker_key` (a new worker is a client with
|
|
429
|
+
another key; a Bridge's is its port). `EventStream` reads the opened socket; `BridgeClient::StreamRequest`
|
|
430
|
+
writes the stream's GET (host, port, path, extra headers) and reads its headers, for a stream through chi web too.
|
|
427
431
|
- `RunOptions.parse(argv)` is bin/chi's one run-flag parser (`RunOptions.switches` lists its flags), so a remote
|
|
428
432
|
branch can take the parsed options before bin/chi's local checks; `LaunchMode.attached_conflict` and
|
|
429
433
|
`LaunchMode::SCRATCH_LEFTOVER` / `BRIDGE_LATE` are the words both would print.
|
data/docs/internals/web-push.md
CHANGED
|
@@ -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.
|
|
71
|
-
subscribe, which the page keeps) drops that entry on this origin too,
|
|
72
|
-
yields it (the route logs `push_replaced`); its own endpoint is never
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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)
|
|
277
|
-
|
|
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
|
@@ -579,13 +579,17 @@ end
|
|
|
579
579
|
- Each task runs once per session start. A `-p … --non-interactive` run
|
|
580
580
|
starts them with its turn and shows nothing but the answer.
|
|
581
581
|
|
|
582
|
-
### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil)`
|
|
582
|
+
### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)`
|
|
583
583
|
|
|
584
584
|
A toolkit sessions can be put on: `base`'s tools (`full`, `lean`, `child`, a
|
|
585
585
|
config or bundle toolkit) with `add` declared and `remove` left out, and
|
|
586
586
|
`prompt`: text the sessions on it get in their system prompt, verbatim (nil
|
|
587
587
|
inherits the base's, `""` clears it; at most 2000 characters, and one that
|
|
588
|
-
isn't a String or is longer is a load error).
|
|
588
|
+
isn't a String or is longer is a load error). `groups` selects the source
|
|
589
|
+
groups the toolkit reaches (names or globs, sorted and deduplicated; nil
|
|
590
|
+
inherits the base's, `[]` reaches none, and `:all` reaches every group; anything
|
|
591
|
+
else, a map say, is a load error).
|
|
592
|
+
Groups match against the tool index's load-time names. Users pick
|
|
589
593
|
it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
|
|
590
594
|
and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
|
|
591
595
|
`-`; one chi has (`full`, `lean`, `child`) or another bundle declared is a
|
|
@@ -593,11 +597,13 @@ load error. A session saved on a toolkit whose bundle is gone declares every
|
|
|
593
597
|
tool (full) until it comes back, with a warning at its first turn. See
|
|
594
598
|
[Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
|
|
595
599
|
chi refuses the keyword, a load error: a bundle that passes it sets
|
|
596
|
-
`requires_chi: ">= 0.72.0"`).
|
|
600
|
+
`requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
|
|
601
|
+
too, so a bundle passing it sets `requires_chi: ">= 0.121.0"` (`chi bundle check` warns on a lower one).
|
|
597
602
|
|
|
598
603
|
```ruby
|
|
599
604
|
chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
|
|
600
605
|
chi.toolkit "coding", base: "child", prompt: "- Be concise in your responses"
|
|
606
|
+
chi.toolkit "web", base: "lean", groups: %w[chrome db*]
|
|
601
607
|
```
|
|
602
608
|
|
|
603
609
|
### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
|
|
@@ -611,12 +617,19 @@ first source's `label`). The index has one group of its own, **`chi`**: the
|
|
|
611
617
|
built-ins the session's toolkit leaves out but doesn't remove
|
|
612
618
|
([toolkits](configuration.md#toolkits)), each with its registered schema.
|
|
613
619
|
The two tools are registered once, after the plugins load, and declared per
|
|
614
|
-
toolkit
|
|
615
|
-
|
|
620
|
+
toolkit when it reaches a source group or has a reachable built-in (`full`
|
|
621
|
+
with no source declares neither). Nothing a
|
|
616
622
|
source publishes later changes the request's tools, only what a search
|
|
617
623
|
answers, so the prompt cache keeps: the search tool's description lists the
|
|
618
624
|
source groups as they were at load, and a `chi` line first, per toolkit.
|
|
619
|
-
The mcp bundle is the only source so far (a group per server).
|
|
625
|
+
The mcp bundle is the only source so far (a group per server). `groups:`
|
|
626
|
+
filters the groups find_tools searches, the source descriptions in the
|
|
627
|
+
request, and the MCP tools `call_tool` can reach. Its group snapshot is
|
|
628
|
+
filtered again at each toolkit change; new groups published after load do
|
|
629
|
+
not alter that prompt description. An unreached MCP tool call is refused
|
|
630
|
+
before the guardrails without starting the server; a deferred stub says to
|
|
631
|
+
switch to a toolkit that reaches its group. `/mcp` marks servers outside the
|
|
632
|
+
session's toolkit. A source's
|
|
620
633
|
group named `chi`, or named like an earlier group, is left out of search,
|
|
621
634
|
lookup and the description, with one warning per name.
|
|
622
635
|
|
|
@@ -1002,6 +1015,9 @@ ctx.sessions.review(subjects: ["docs/plan.md"], seed: handoff) # => a review se
|
|
|
1002
1015
|
`/title` of its own (see [Session titles](sessions.md#session-titles)).
|
|
1003
1016
|
- An image a message names is copied into the child. One whose file is
|
|
1004
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!`).
|
|
1005
1021
|
- `review(subjects: [], seed: nil, recap_wait: 0)` starts a review session of
|
|
1006
1022
|
this one, as `chi review --from` does (see [Review sessions](sessions.md#review-sessions)):
|
|
1007
1023
|
a new session in this session's folder, on its model and setup, seeded with
|
|
@@ -1377,8 +1393,12 @@ bundles:
|
|
|
1377
1393
|
its tools as `(failed: …)`. The saved list stays: the next session tries
|
|
1378
1394
|
again.
|
|
1379
1395
|
- **`start: eager`** on a server starts it with every session (in an init
|
|
1380
|
-
task, after the Bridge is up),
|
|
1381
|
-
|
|
1396
|
+
task, after the Bridge is up), even when that session's toolkit does not
|
|
1397
|
+
reach its group. A first-run init task still blocks the first turn until
|
|
1398
|
+
it finishes, even for an unreached group. A stale-cache refresh likewise
|
|
1399
|
+
starts its short-lived server regardless of the toolkit. Switching away
|
|
1400
|
+
from a group refuses its calls without starting a lazy cached server, and
|
|
1401
|
+
does not stop a server already running; switching back reuses it.
|
|
1382
1402
|
- **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
|
|
1383
1403
|
`chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
|
|
1384
1404
|
hides it from new sessions, but not from the first call. For a faster first
|
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 `
|
|
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`,
|
|
@@ -230,7 +265,9 @@ A builder runs these while working, not the full suite: the specs the change tou
|
|
|
230
265
|
files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
|
|
231
266
|
docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
|
|
232
267
|
file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
|
|
233
|
-
words the REPL prints, pick it by a rule)
|
|
268
|
+
words the REPL prints, pick it by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
|
|
269
|
+
effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
|
|
270
|
+
tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
|
|
234
271
|
`toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
|
|
235
272
|
`spec/guardrails/context_spec.rb`), then the specs that name a method the change touches (the innermost `def` around a
|
|
236
273
|
changed line, read with Prism; one `git grep` for up to 12 names; names under 4 characters, `initialize`, `to_s`,
|
|
@@ -355,3 +392,8 @@ kept with the build id `/status` shows (`identity.cache.build_id`); when that ch
|
|
|
355
392
|
the version and record it again. This step goes once a Splash release carries its version in `/status`.
|
|
356
393
|
|
|
357
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
|
|
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
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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)&.
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 :
|
|
169
|
-
return :
|
|
170
|
-
return :
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
raise
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
# The request's bytes.
|