@huynle/hub 0.6.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/central/spoke-ws.ts
CHANGED
|
@@ -1,25 +1,62 @@
|
|
|
1
1
|
// Spoke WebSocket endpoint (/spoke/ws): bearer-token authentication, hello/welcome handshake, frame
|
|
2
2
|
// validation, heartbeats, server→spoke request/response correlation, and spoke→server requests.
|
|
3
|
+
// `hub node` daemons share the endpoint and the machine token but say hello with role "node" (design §6.3):
|
|
4
|
+
// they go to the NodeRegistry, never to the spoke Registry, and get node ops (requestNode) instead of spoke ops.
|
|
5
|
+
// A harness inside a sandbox (phase 5) authenticates with its launch's hlt1 token instead (./agent/launch-tokens.ts):
|
|
6
|
+
// accepted only while the launch is provisioning or running, only with a spoke hello naming that launch, one
|
|
7
|
+
// connection per launch (a reconnect replaces the old one), closed when the launch ends (closeLaunch) or the token
|
|
8
|
+
// expires; such a connection is bound to the launch in the Registry and gets no pairing or login codes.
|
|
9
|
+
// Source addresses (loopback included: a sandbox reaches a loopback hub through its host) are never trusted.
|
|
10
|
+
// Cluster mode (design §9, `cluster` option): each connection is recorded in the ConnDirectory (connID → pod); an op
|
|
11
|
+
// for a connection another pod holds goes there over pod RPC (./cluster/rpc.ts) with the same per-op timeout, and the
|
|
12
|
+
// owning pod re-checks the tenant before sending it. closeMachine / closeLaunch reach every pod. Session-token and
|
|
13
|
+
// act-token pushes are sent only by the pod holding the connection. Close notices are at most once, so every
|
|
14
|
+
// RECHECK_MS each pod also re-reads its sockets' machines and owners (Store.recheck) and closes the sockets of a
|
|
15
|
+
// revoked machine or a disabled owner itself. Without `cluster` everything is local.
|
|
16
|
+
// Draining (design §10, drain()): new upgrades are refused with 503, new requests to this pod's connections fail as
|
|
17
|
+
// offline, and each connection gets bye{reason:"draining", retryAfterMs} once its requests in flight are answered
|
|
18
|
+
// (at the deadline at the latest), so it reconnects (to another pod) after a short jittered delay.
|
|
19
|
+
// Harness events (phase 7): a spoke of a harness other than OpenCode may send only the harness event subset
|
|
20
|
+
// (harnessEventProblem); an event outside it, or with invalid data, is dropped and logged, never fatal. OpenCode
|
|
21
|
+
// spokes' events are not checked this way (their native shapes are the hub's own).
|
|
3
22
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
|
4
23
|
import {
|
|
5
24
|
encode,
|
|
25
|
+
harnessEventProblem,
|
|
26
|
+
helloHarness,
|
|
27
|
+
helloRole,
|
|
28
|
+
isOpenCodeHello,
|
|
29
|
+
HUB_FEATURES,
|
|
6
30
|
MAX_FRAME,
|
|
31
|
+
NODE_ROLE,
|
|
32
|
+
nodeResultProblem,
|
|
33
|
+
parseNodeMessage,
|
|
7
34
|
parseSpokeMessage,
|
|
8
35
|
PROTOCOL_VERSION,
|
|
36
|
+
type HelloRole,
|
|
37
|
+
type NodeHello,
|
|
38
|
+
type NodeOp,
|
|
39
|
+
type NodeRequest,
|
|
40
|
+
type NodeResultFor,
|
|
41
|
+
type NodeServerMessage,
|
|
9
42
|
type Req,
|
|
10
43
|
type ResultFor,
|
|
11
44
|
type ServerMessage,
|
|
12
45
|
type ServerRequest,
|
|
13
46
|
type SpokeRequest,
|
|
14
47
|
} from "../protocol"
|
|
48
|
+
import type { LaunchBinding, LaunchVerifyResult } from "./agent/launch-tokens"
|
|
49
|
+
import type { ConnDirectory } from "./cluster/directory"
|
|
50
|
+
import type { CloseNotice, PodRpc, RpcCall } from "./cluster/rpc"
|
|
15
51
|
import type { Auth } from "./auth"
|
|
16
|
-
import type {
|
|
52
|
+
import type { NodeRegistry } from "./nodes"
|
|
53
|
+
import type { NotificationKind, PushExtra } from "./push"
|
|
17
54
|
import type { Registry } from "./registry"
|
|
18
55
|
import { newID, type Store } from "./store"
|
|
19
56
|
|
|
20
57
|
/** Push notifications (implemented by Push in push.ts) to `ownerID`'s devices. Returns the number of devices notified. */
|
|
21
58
|
export interface Notifier {
|
|
22
|
-
notify(kind:
|
|
59
|
+
notify(kind: NotificationKind, sessionID: string, title: string, body?: string, ownerID?: string, extra?: PushExtra): Promise<number>
|
|
23
60
|
}
|
|
24
61
|
|
|
25
62
|
/** The spoke answered with an error, or disconnected before answering. */
|
|
@@ -30,6 +67,14 @@ export class SpokeError extends Error {
|
|
|
30
67
|
}
|
|
31
68
|
}
|
|
32
69
|
|
|
70
|
+
/** A node answered with an error or an invalid result, or disconnected before answering. */
|
|
71
|
+
export class NodeError extends Error {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message)
|
|
74
|
+
this.name = "NodeError"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
33
78
|
/** A request that would not fit in one protocol frame (the spoke would drop it and never answer). */
|
|
34
79
|
export class RequestTooLargeError extends Error {
|
|
35
80
|
constructor(op: string) {
|
|
@@ -39,8 +84,8 @@ export class RequestTooLargeError extends Error {
|
|
|
39
84
|
}
|
|
40
85
|
|
|
41
86
|
export class RequestTimeoutError extends Error {
|
|
42
|
-
constructor(op: string) {
|
|
43
|
-
super(
|
|
87
|
+
constructor(op: string, who = "OpenCode") {
|
|
88
|
+
super(`${who} did not answer the ${op} request in time`)
|
|
44
89
|
this.name = "RequestTimeoutError"
|
|
45
90
|
}
|
|
46
91
|
}
|
|
@@ -50,8 +95,20 @@ export interface SpokeSocketData {
|
|
|
50
95
|
readonly machineID: string
|
|
51
96
|
readonly machineName: string
|
|
52
97
|
ready: boolean
|
|
98
|
+
/** What the hello said this connection is (set with the hello). */
|
|
99
|
+
role?: HelloRole
|
|
53
100
|
lastSeen: number
|
|
54
101
|
helloTimer?: ReturnType<typeof setTimeout>
|
|
102
|
+
/** The launch whose launch-bound token authenticated this connection (absent for machine tokens). */
|
|
103
|
+
readonly launch?: LaunchBinding
|
|
104
|
+
/** Closes a launch-bound connection when its token expires. */
|
|
105
|
+
expiryTimer?: ReturnType<typeof setTimeout>
|
|
106
|
+
/** Draining: the bye was sent. */
|
|
107
|
+
drained?: boolean
|
|
108
|
+
/** A non-OpenCode spoke's harness (set with the hello): its events are checked against the harness subset. */
|
|
109
|
+
checkedHarness?: string
|
|
110
|
+
/** Events of a non-OpenCode spoke dropped as outside the harness subset. */
|
|
111
|
+
droppedEvents?: number
|
|
55
112
|
}
|
|
56
113
|
|
|
57
114
|
type Socket = ServerWebSocket<SpokeSocketData>
|
|
@@ -60,6 +117,8 @@ export interface SpokeHubOptions {
|
|
|
60
117
|
readonly store: Store
|
|
61
118
|
readonly auth: Auth
|
|
62
119
|
readonly registry: Registry
|
|
120
|
+
/** Connected `hub node` daemons (role "node" hellos); nodes never enter `registry`. */
|
|
121
|
+
readonly nodes: NodeRegistry
|
|
63
122
|
readonly publicURL: string
|
|
64
123
|
readonly notifier: Notifier
|
|
65
124
|
/** Sends a message to the SSE subscribers of `tenant` (a user ID). */
|
|
@@ -71,11 +130,41 @@ export interface SpokeHubOptions {
|
|
|
71
130
|
readonly helloTimeoutMs?: number
|
|
72
131
|
readonly heartbeatMs?: number
|
|
73
132
|
readonly staleMs?: number
|
|
133
|
+
/** Per node op request timeouts (defaults: NODE_TIMEOUTS_MS; cancel uses requestTimeoutMs). */
|
|
134
|
+
readonly nodeTimeoutsMs?: Partial<Record<NodeOp, number>>
|
|
135
|
+
/** Cluster mode: how often the sockets' machines and owners are re-checked (default RECHECK_MS). */
|
|
136
|
+
readonly recheckMs?: number
|
|
137
|
+
/** Verifies launch-bound (hlt1) tokens; without it only machine tokens are accepted. */
|
|
138
|
+
readonly launchTokens?: LaunchVerifier
|
|
139
|
+
/** Cluster mode: the shared connection directory and this pod's RPC (absent: every connection is local). */
|
|
140
|
+
readonly cluster?: SpokeCluster
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SpokeCluster {
|
|
144
|
+
readonly directory: ConnDirectory
|
|
145
|
+
readonly rpc: PodRpc
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Per-request options. */
|
|
149
|
+
export interface RequestOptions {
|
|
150
|
+
/**
|
|
151
|
+
* The user the caller acts for. For a connection on another pod, that pod refuses the op unless the connection's
|
|
152
|
+
* machine belongs to this user (default: the machine's owner as this pod sees it) and the session, if any, is
|
|
153
|
+
* not another user's.
|
|
154
|
+
*/
|
|
155
|
+
readonly tenant?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Checks launch-bound tokens (implemented by LaunchTokens). */
|
|
159
|
+
export interface LaunchVerifier {
|
|
160
|
+
verify(token: string): Promise<LaunchVerifyResult>
|
|
74
161
|
}
|
|
75
162
|
|
|
76
163
|
interface Pending {
|
|
77
164
|
readonly connID: string
|
|
78
165
|
readonly op: string
|
|
166
|
+
/** A node request: errors are NodeErrors and the result is checked with nodeResultProblem. */
|
|
167
|
+
readonly node?: boolean
|
|
79
168
|
readonly resolve: (value: unknown) => void
|
|
80
169
|
readonly reject: (error: Error) => void
|
|
81
170
|
readonly timer: ReturnType<typeof setTimeout>
|
|
@@ -85,20 +174,67 @@ interface Pending {
|
|
|
85
174
|
export const TURN_GRACE_MS = 15_000
|
|
86
175
|
/** Creating a session and opening it in a new Herdr tab (the spoke allows `herdr agent start` 20 s). */
|
|
87
176
|
export const CREATE_IN_HERDR_TIMEOUT_MS = 45_000
|
|
177
|
+
/** Auto-name: the spoke waits up to 20 s for OpenCode's generated title, then answers "pending". */
|
|
178
|
+
export const AUTO_NAME_TIMEOUT_MS = 30_000
|
|
179
|
+
|
|
180
|
+
/** Node op timeouts: prepare may clone a repository; collect and cleanup run git in an existing workspace. */
|
|
181
|
+
export const NODE_TIMEOUTS_MS: Readonly<Partial<Record<NodeOp, number>>> = { prepare: 600_000, collect: 60_000, cleanup: 120_000 }
|
|
88
182
|
|
|
89
183
|
const CLOSE_PROTOCOL = 4000
|
|
90
184
|
const CLOSE_REVOKED = 4001
|
|
91
185
|
const CLOSE_STALE = 4002
|
|
186
|
+
const CLOSE_REPLACED = 4003
|
|
187
|
+
const CLOSE_LAUNCH_ENDED = 4004
|
|
188
|
+
const CLOSE_LAUNCH_EXPIRED = 4005
|
|
189
|
+
/** Longest delay setTimeout takes; later expiries re-arm. */
|
|
190
|
+
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
191
|
+
const LAUNCH_TOKEN = /^hlt1\./
|
|
192
|
+
/** How long stop() waits for the cluster directory and RPC to be cleaned up. */
|
|
193
|
+
const CLUSTER_STOP_MS = 1_000
|
|
194
|
+
/** How many closed launches are remembered (launch IDs are never reused). */
|
|
195
|
+
const CLOSED_LAUNCHES = 1000
|
|
196
|
+
/** How soon drained spokes and nodes are told to reconnect (they wait a random 0–this; design §10: 0–5 s). */
|
|
197
|
+
export const DRAIN_RETRY_AFTER_MS = 5_000
|
|
198
|
+
/** How long draining waits for requests in flight (k8s terminationGracePeriodSeconds is 60). */
|
|
199
|
+
export const DRAIN_TIMEOUT_MS = 50_000
|
|
200
|
+
/** Close code of a drained connection (a hub code: Bun 1.3.5 reports a server's 1001 to clients as 1000). */
|
|
201
|
+
const CLOSE_DRAINING = 4006
|
|
202
|
+
/** How often draining looks whether the requests in flight are done. */
|
|
203
|
+
const DRAIN_POLL_MS = 20
|
|
204
|
+
|
|
205
|
+
export interface DrainOptions {
|
|
206
|
+
/** Sent in the bye: the peers reconnect after a random 0–retryAfterMs (default DRAIN_RETRY_AFTER_MS). */
|
|
207
|
+
readonly retryAfterMs?: number
|
|
208
|
+
/** How long to wait for requests in flight (default DRAIN_TIMEOUT_MS); the rest fail as offline. */
|
|
209
|
+
readonly timeoutMs?: number
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Cluster mode: how often a pod re-checks that its sockets' machines are not revoked and their owners not disabled. */
|
|
213
|
+
export const RECHECK_MS = 30_000
|
|
92
214
|
|
|
93
215
|
export class SpokeHub {
|
|
94
216
|
private readonly sockets = new Map<string, Socket>()
|
|
95
217
|
private readonly pending = new Map<string, Pending>()
|
|
218
|
+
/**
|
|
219
|
+
* Launches closed here, with the reason: a socket whose token was verified just before its launch ended opens
|
|
220
|
+
* after closeLaunch ran, and is turned away then.
|
|
221
|
+
*/
|
|
222
|
+
private readonly closedLaunches = new Map<string, string>()
|
|
96
223
|
private readonly heartbeat: ReturnType<typeof setInterval>
|
|
224
|
+
private readonly recheckTimer?: ReturnType<typeof setInterval>
|
|
97
225
|
private seq = 0
|
|
226
|
+
/** Set by drain(): the bye's retryAfterMs. */
|
|
227
|
+
private draining?: { readonly retryAfterMs: number }
|
|
228
|
+
private drained?: Promise<void>
|
|
229
|
+
/** Calls this pod made to connections on other pods, not answered yet (draining waits for them). */
|
|
230
|
+
private remoteCalls = 0
|
|
231
|
+
/** Cluster mode: the directory heartbeat, RPC responder and close notices are set up. */
|
|
232
|
+
private readonly started: Promise<void>
|
|
98
233
|
readonly websocket: WebSocketHandler<SpokeSocketData>
|
|
99
234
|
|
|
100
235
|
constructor(private readonly options: SpokeHubOptions) {
|
|
101
236
|
const staleMs = options.staleMs ?? 60_000
|
|
237
|
+
this.started = this.startCluster()
|
|
102
238
|
this.heartbeat = setInterval(() => {
|
|
103
239
|
const now = Date.now()
|
|
104
240
|
for (const ws of this.sockets.values()) {
|
|
@@ -107,6 +243,10 @@ export class SpokeHub {
|
|
|
107
243
|
}
|
|
108
244
|
}, options.heartbeatMs ?? 20_000)
|
|
109
245
|
this.heartbeat.unref?.()
|
|
246
|
+
if (options.cluster) {
|
|
247
|
+
this.recheckTimer = setInterval(() => void this.recheck(), options.recheckMs ?? RECHECK_MS)
|
|
248
|
+
this.recheckTimer.unref?.()
|
|
249
|
+
}
|
|
110
250
|
|
|
111
251
|
this.websocket = {
|
|
112
252
|
open: (ws) => this.open(ws),
|
|
@@ -117,71 +257,306 @@ export class SpokeHub {
|
|
|
117
257
|
}
|
|
118
258
|
}
|
|
119
259
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Authenticates and upgrades /spoke/ws with a machine token or a launch-bound (hlt1) token. Resolves with a
|
|
262
|
+
* Response on failure, undefined when upgraded.
|
|
263
|
+
*/
|
|
264
|
+
async upgrade(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
265
|
+
if (this.draining) {
|
|
266
|
+
const retryAfter = String(Math.max(1, Math.ceil(this.draining.retryAfterMs / 1000)))
|
|
267
|
+
return Response.json({ error: "This hub server is shutting down; reconnect", code: "draining" }, { status: 503, headers: { "retry-after": retryAfter } })
|
|
268
|
+
}
|
|
122
269
|
const match = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")
|
|
123
|
-
const
|
|
270
|
+
const token = match?.[1]
|
|
271
|
+
if (token && LAUNCH_TOKEN.test(token)) return this.upgradeLaunch(request, server, token)
|
|
272
|
+
const machine = token ? await this.options.store.findMachine(token) : undefined
|
|
273
|
+
if (machine) await this.options.store.recheck({ users: [machine.ownerID] })
|
|
124
274
|
const owner = machine && this.options.store.user(machine.ownerID)
|
|
125
275
|
if (!machine || !owner || owner.disabled) return Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
276
|
+
return this.accept(request, server, { connID: newID("conn"), machineID: machine.id, machineName: machine.name, ready: false, lastSeen: Date.now() })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async upgradeLaunch(request: Request, server: Server<SpokeSocketData>, token: string): Promise<Response | undefined> {
|
|
280
|
+
const refused = () => Response.json({ error: "Invalid or expired launch token" }, { status: 401 })
|
|
281
|
+
const verifier = this.options.launchTokens
|
|
282
|
+
if (!verifier) return refused()
|
|
283
|
+
let binding: LaunchVerifyResult
|
|
284
|
+
try {
|
|
285
|
+
binding = await verifier.verify(token)
|
|
286
|
+
} catch {
|
|
287
|
+
return refused()
|
|
132
288
|
}
|
|
289
|
+
if (!binding || binding === "expired") return refused()
|
|
290
|
+
const machine = this.options.store.machines().find((m) => m.id === binding.machineID)
|
|
291
|
+
if (!machine) return refused()
|
|
292
|
+
return this.accept(request, server, { connID: newID("conn"), machineID: machine.id, machineName: machine.name, ready: false, lastSeen: Date.now(), launch: binding })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private accept(request: Request, server: Server<SpokeSocketData>, data: SpokeSocketData): Response | undefined {
|
|
133
296
|
if (server.upgrade(request, { data })) return undefined
|
|
134
297
|
return Response.json({ error: "Expected a WebSocket upgrade" }, { status: 426 })
|
|
135
298
|
}
|
|
136
299
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
300
|
+
/** Resolves once cluster mode is set up (at once without it). */
|
|
301
|
+
ready(): Promise<void> {
|
|
302
|
+
return this.started
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Sends a request to a connected spoke and resolves with its result. In cluster mode a connection another pod
|
|
307
|
+
* holds is reached over pod RPC.
|
|
308
|
+
*/
|
|
309
|
+
request<R extends ServerRequest>(connID: string, req: R, options: RequestOptions = {}): Promise<ResultFor[R["op"]]> {
|
|
139
310
|
const ws = this.sockets.get(connID)
|
|
140
|
-
if (!ws
|
|
311
|
+
if (!ws && this.options.cluster) return this.remote({ kind: "spoke", connID, req, tenant: options.tenant ?? "" }) as Promise<ResultFor[R["op"]]>
|
|
312
|
+
if (!ws?.data.ready || ws.data.role !== "spoke" || this.draining) return Promise.reject(new SpokeError("OpenCode process disconnected"))
|
|
141
313
|
const id = `r${++this.seq}`
|
|
142
314
|
const frame = encode({ t: "req", id, req })
|
|
143
315
|
if (frame.length > MAX_FRAME) return Promise.reject(new RequestTooLargeError(req.op))
|
|
144
|
-
const
|
|
145
|
-
const timeoutMs =
|
|
146
|
-
req.op === "turn"
|
|
147
|
-
? this.options.turnTimeoutMs + (this.options.turnGraceMs ?? TURN_GRACE_MS)
|
|
148
|
-
: req.op === "create" && req.herdrWorkspace
|
|
149
|
-
? Math.max(base, CREATE_IN_HERDR_TIMEOUT_MS) // the spoke waits for OpenCode to start in the new tab
|
|
150
|
-
: base
|
|
316
|
+
const timeoutMs = this.spokeTimeoutMs(req)
|
|
151
317
|
return new Promise((resolve, reject) => {
|
|
152
318
|
const timer = setTimeout(() => {
|
|
153
319
|
this.pending.delete(id)
|
|
154
320
|
reject(new RequestTimeoutError(req.op))
|
|
321
|
+
this.released(connID)
|
|
155
322
|
}, timeoutMs)
|
|
156
323
|
this.pending.set(id, { connID, op: req.op, resolve: resolve as (v: unknown) => void, reject, timer })
|
|
157
324
|
ws.send(frame)
|
|
158
325
|
})
|
|
159
326
|
}
|
|
160
327
|
|
|
161
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* Sends an op to a connected node and resolves with its result, checked with nodeResultProblem. In cluster mode a
|
|
330
|
+
* node another pod holds is reached over pod RPC.
|
|
331
|
+
*/
|
|
332
|
+
requestNode<R extends NodeRequest>(connID: string, req: R, options: RequestOptions = {}): Promise<NodeResultFor[R["op"]]> {
|
|
333
|
+
const ws = this.sockets.get(connID)
|
|
334
|
+
if (!ws && this.options.cluster) return this.remote({ kind: "node", connID, req, tenant: options.tenant ?? "" }) as Promise<NodeResultFor[R["op"]]>
|
|
335
|
+
if (!ws?.data.ready || ws.data.role !== NODE_ROLE || this.draining) return Promise.reject(new NodeError("The node disconnected"))
|
|
336
|
+
const id = `r${++this.seq}`
|
|
337
|
+
const frame = encode({ t: "req", id, req } satisfies NodeServerMessage)
|
|
338
|
+
if (frame.length > MAX_FRAME) return Promise.reject(new NodeError(`The ${req.op} request is too large to send to the node`))
|
|
339
|
+
const timeoutMs = this.nodeTimeoutMs(req)
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
const timer = setTimeout(() => {
|
|
342
|
+
this.pending.delete(id)
|
|
343
|
+
reject(new RequestTimeoutError(req.op, "The node"))
|
|
344
|
+
this.released(connID)
|
|
345
|
+
}, timeoutMs)
|
|
346
|
+
this.pending.set(id, { connID, op: req.op, node: true, resolve: resolve as (v: unknown) => void, reject, timer })
|
|
347
|
+
ws.send(frame)
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Disconnects the connections bound to a launch (when it ends), on every pod. */
|
|
352
|
+
closeLaunch(launchID: string, reason = "launch ended"): void {
|
|
353
|
+
this.closeLaunchHere(launchID, reason)
|
|
354
|
+
this.publishClose({ kind: "launch", id: launchID, reason })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Disconnects every socket of a machine (after revocation), on every pod. */
|
|
162
358
|
closeMachine(machineID: string, reason: string): void {
|
|
163
|
-
|
|
359
|
+
this.closeMachineHere(machineID, reason)
|
|
360
|
+
this.publishClose({ kind: "machine", id: machineID, reason })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Draining (design §10): refuses new upgrades and requests, says bye{reason:"draining", retryAfterMs} to each
|
|
365
|
+
* connection once its requests in flight are answered, and resolves when they all are (or at the deadline, when the
|
|
366
|
+
* rest fail as offline and every connection gets its bye). Repeated calls share the first one.
|
|
367
|
+
*/
|
|
368
|
+
drain(options: DrainOptions = {}): Promise<void> {
|
|
369
|
+
this.drained ??= this.runDrain(options.retryAfterMs ?? DRAIN_RETRY_AFTER_MS, options.timeoutMs ?? DRAIN_TIMEOUT_MS)
|
|
370
|
+
return this.drained
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Whether drain() was called. */
|
|
374
|
+
isDraining(): boolean {
|
|
375
|
+
return this.draining !== undefined
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async runDrain(retryAfterMs: number, timeoutMs: number): Promise<void> {
|
|
379
|
+
this.draining = { retryAfterMs }
|
|
380
|
+
for (const ws of [...this.sockets.values()]) this.released(ws.data.connID)
|
|
381
|
+
const deadline = Date.now() + timeoutMs
|
|
382
|
+
while ((this.pending.size > 0 || this.remoteCalls > 0) && Date.now() < deadline) await Bun.sleep(Math.min(DRAIN_POLL_MS, Math.max(1, deadline - Date.now())))
|
|
383
|
+
for (const [id, pending] of this.pending) {
|
|
384
|
+
this.pending.delete(id)
|
|
385
|
+
clearTimeout(pending.timer)
|
|
386
|
+
pending.reject(pending.node ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected"))
|
|
387
|
+
}
|
|
388
|
+
for (const ws of [...this.sockets.values()]) this.byeDraining(ws)
|
|
164
389
|
}
|
|
165
390
|
|
|
166
|
-
|
|
391
|
+
/** Draining: a connection with no request in flight any more gets its bye. */
|
|
392
|
+
private released(connID: string): void {
|
|
393
|
+
if (!this.draining) return
|
|
394
|
+
const ws = this.sockets.get(connID)
|
|
395
|
+
if (!ws) return
|
|
396
|
+
for (const pending of this.pending.values()) if (pending.connID === connID) return
|
|
397
|
+
this.byeDraining(ws)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private byeDraining(ws: Socket): void {
|
|
401
|
+
if (!this.draining || ws.data.drained) return
|
|
402
|
+
ws.data.drained = true
|
|
403
|
+
this.send(ws, { t: "bye", reason: "draining", retryAfterMs: this.draining.retryAfterMs })
|
|
404
|
+
ws.close(CLOSE_DRAINING, "draining")
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async stop(): Promise<void> {
|
|
167
408
|
clearInterval(this.heartbeat)
|
|
168
|
-
|
|
409
|
+
clearInterval(this.recheckTimer)
|
|
410
|
+
for (const ws of this.sockets.values()) if (!ws.data.drained) this.bye(ws, "server shutting down", 1001)
|
|
411
|
+
const { cluster } = this.options
|
|
412
|
+
if (!cluster) return
|
|
413
|
+
// Best effort and bounded: the entries expire anyway, and an unreachable Valkey must not hold up shutdown.
|
|
414
|
+
const cleanup = this.started.catch(() => undefined).then(() => Promise.allSettled([cluster.rpc.stop(), cluster.directory.stop()]))
|
|
415
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
416
|
+
await Promise.race([cleanup, new Promise<void>((resolve) => (timer = setTimeout(resolve, CLUSTER_STOP_MS)))])
|
|
417
|
+
clearTimeout(timer)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private closeLaunchHere(launchID: string, reason: string): void {
|
|
421
|
+
this.closedLaunches.set(launchID, reason)
|
|
422
|
+
if (this.closedLaunches.size > CLOSED_LAUNCHES) this.closedLaunches.delete(this.closedLaunches.keys().next().value!)
|
|
423
|
+
for (const ws of this.sockets.values()) if (ws.data.launch?.launchID === launchID) this.bye(ws, reason, CLOSE_LAUNCH_ENDED)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private closeMachineHere(machineID: string, reason: string): void {
|
|
427
|
+
for (const ws of this.sockets.values()) if (ws.data.machineID === machineID) this.bye(ws, reason, CLOSE_REVOKED)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Cluster mode: re-reads the machines of this pod's ready sockets and their owners from the database and closes the
|
|
432
|
+
* sockets of a revoked (or removed) machine or a disabled owner, in case the close notice from another pod was lost.
|
|
433
|
+
*/
|
|
434
|
+
async recheck(): Promise<void> {
|
|
435
|
+
const machineIDs = [...new Set([...this.sockets.values()].filter((ws) => ws.data.ready).map((ws) => ws.data.machineID))]
|
|
436
|
+
if (!machineIDs.length) return
|
|
437
|
+
const { store } = this.options
|
|
438
|
+
try {
|
|
439
|
+
await store.recheck({ machines: machineIDs, users: machineIDs.map((id) => this.ownerOf(id)) })
|
|
440
|
+
// Ownership may have moved: the current owners too.
|
|
441
|
+
await store.recheck({ users: machineIDs.map((id) => this.ownerOf(id)) })
|
|
442
|
+
} catch (cause) {
|
|
443
|
+
console.error(`[hub] machine re-check failed: ${cause instanceof Error ? cause.message : cause}`)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
for (const machineID of machineIDs) {
|
|
447
|
+
const machine = store.machines().find((m) => m.id === machineID)
|
|
448
|
+
const owner = machine && store.user(machine.ownerID)
|
|
449
|
+
if (!machine || machine.revoked) this.closeMachineHere(machineID, "machine revoked")
|
|
450
|
+
else if (!owner || owner.disabled) this.closeMachineHere(machineID, "account disabled")
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private publishClose(notice: Omit<CloseNotice, "from">): void {
|
|
455
|
+
this.options.cluster?.rpc.publishClose(notice).catch((cause) => {
|
|
456
|
+
console.error(`[hub] close notice not sent to the other pods: ${cause instanceof Error ? cause.message : cause}`)
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Cluster mode: heartbeat the directory, answer RPC for this pod's connections, apply other pods' close notices. */
|
|
461
|
+
private async startCluster(): Promise<void> {
|
|
462
|
+
const { cluster } = this.options
|
|
463
|
+
if (!cluster) return
|
|
464
|
+
await cluster.directory.start()
|
|
465
|
+
await cluster.rpc.serve((call) => this.serveRpc(call))
|
|
466
|
+
await cluster.rpc.onClose((notice) => {
|
|
467
|
+
if (notice.from === cluster.directory.podID) return
|
|
468
|
+
if (notice.kind === "machine") this.closeMachineHere(notice.id, notice.reason)
|
|
469
|
+
else this.closeLaunchHere(notice.id, notice.reason)
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The hub-side wait for a spoke op. */
|
|
474
|
+
private spokeTimeoutMs(req: ServerRequest): number {
|
|
475
|
+
const base = this.options.requestTimeoutMs ?? 15_000
|
|
476
|
+
return req.op === "turn"
|
|
477
|
+
? this.options.turnTimeoutMs + (this.options.turnGraceMs ?? TURN_GRACE_MS)
|
|
478
|
+
: req.op === "create" && req.herdrWorkspace
|
|
479
|
+
? Math.max(base, CREATE_IN_HERDR_TIMEOUT_MS) // the spoke waits for OpenCode to start in the new tab
|
|
480
|
+
: req.op === "autoName"
|
|
481
|
+
? Math.max(base, AUTO_NAME_TIMEOUT_MS)
|
|
482
|
+
: base
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private nodeTimeoutMs(req: NodeRequest): number {
|
|
486
|
+
return this.options.nodeTimeoutsMs?.[req.op] ?? NODE_TIMEOUTS_MS[req.op] ?? this.options.requestTimeoutMs ?? 15_000
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** An op for a connection this pod does not hold: to the pod the directory names, over RPC. */
|
|
490
|
+
private async remote(call: RpcCall): Promise<unknown> {
|
|
491
|
+
const { directory, rpc } = this.options.cluster!
|
|
492
|
+
if (encode({ t: "req", id: "r0", req: call.req } as ServerMessage).length > MAX_FRAME) {
|
|
493
|
+
throw call.kind === "node" ? new NodeError(`The ${call.req.op} request is too large to send to the node`) : new RequestTooLargeError(call.req.op)
|
|
494
|
+
}
|
|
495
|
+
const entry = await directory.lookup(call.connID)
|
|
496
|
+
if (!entry || entry.pod === directory.podID) throw offlineError(call.kind)
|
|
497
|
+
const tenant = call.tenant || this.ownerOf(entry.machineID)
|
|
498
|
+
if (!tenant) throw offlineError(call.kind)
|
|
499
|
+
const timeoutMs = call.kind === "node" ? this.nodeTimeoutMs(call.req) : this.spokeTimeoutMs(call.req)
|
|
500
|
+
this.remoteCalls++
|
|
501
|
+
try {
|
|
502
|
+
return await rpc.call(entry.pod, { ...call, tenant } as RpcCall, timeoutMs, {
|
|
503
|
+
onGone: () => void directory.forget(call.connID, entry.pod).catch(() => undefined),
|
|
504
|
+
})
|
|
505
|
+
} finally {
|
|
506
|
+
this.remoteCalls--
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** An op from another pod for a connection here: re-check the tenant, then send it like a local one. */
|
|
511
|
+
private async serveRpc(call: RpcCall): Promise<unknown> {
|
|
512
|
+
const ws = this.sockets.get(call.connID)
|
|
513
|
+
if (!ws?.data.ready) throw offlineError(call.kind)
|
|
514
|
+
const machine = this.options.store.machines().find((m) => m.id === ws.data.machineID)
|
|
515
|
+
if (!machine || machine.revoked || machine.ownerID !== call.tenant) throw offlineError(call.kind)
|
|
516
|
+
const sessionID = (call.req as { sessionID?: unknown }).sessionID
|
|
517
|
+
if (typeof sessionID === "string") {
|
|
518
|
+
const owner = this.options.registry.sessionTenant(sessionID)
|
|
519
|
+
if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
|
|
520
|
+
}
|
|
521
|
+
return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
|
|
169
522
|
}
|
|
170
523
|
|
|
171
524
|
private open(ws: Socket): void {
|
|
172
525
|
this.sockets.set(ws.data.connID, ws)
|
|
526
|
+
// Upgraded just before drain(): send it on its way too.
|
|
527
|
+
if (this.draining) return this.byeDraining(ws)
|
|
528
|
+
if (ws.data.launch) {
|
|
529
|
+
const closed = this.closedLaunches.get(ws.data.launch.launchID)
|
|
530
|
+
if (closed !== undefined) return this.bye(ws, closed, CLOSE_LAUNCH_ENDED)
|
|
531
|
+
this.armExpiry(ws, ws.data.launch.exp)
|
|
532
|
+
}
|
|
173
533
|
ws.data.helloTimer = setTimeout(() => {
|
|
174
534
|
if (!ws.data.ready) this.bye(ws, "hello timeout")
|
|
175
535
|
}, this.options.helloTimeoutMs ?? 10_000)
|
|
176
536
|
}
|
|
177
537
|
|
|
538
|
+
/** Closes a launch-bound connection at its token's expiry. */
|
|
539
|
+
private armExpiry(ws: Socket, exp: number): void {
|
|
540
|
+
const left = exp - Date.now()
|
|
541
|
+
if (left <= 0) return this.bye(ws, "launch token expired", CLOSE_LAUNCH_EXPIRED)
|
|
542
|
+
ws.data.expiryTimer = setTimeout(() => this.armExpiry(ws, exp), Math.min(left, MAX_TIMER_MS))
|
|
543
|
+
ws.data.expiryTimer.unref?.()
|
|
544
|
+
}
|
|
545
|
+
|
|
178
546
|
private seen(ws: Socket): void {
|
|
179
547
|
ws.data.lastSeen = Date.now()
|
|
180
|
-
if (ws.data.ready)
|
|
548
|
+
if (!ws.data.ready) return
|
|
549
|
+
if (ws.data.role === NODE_ROLE) this.options.nodes.touch(ws.data.connID)
|
|
550
|
+
else this.options.registry.touch(ws.data.connID)
|
|
181
551
|
}
|
|
182
552
|
|
|
183
553
|
private message(ws: Socket, raw: string | Buffer): void {
|
|
184
554
|
this.seen(ws)
|
|
555
|
+
if (ws.data.ready ? ws.data.role === NODE_ROLE : helloRole(raw) === NODE_ROLE) {
|
|
556
|
+
// A launch token is for that launch's spoke only.
|
|
557
|
+
if (ws.data.launch) return this.bye(ws, "protocol error")
|
|
558
|
+
return this.nodeMessage(ws, raw)
|
|
559
|
+
}
|
|
185
560
|
const message = parseSpokeMessage(raw)
|
|
186
561
|
if (!message) return this.bye(ws, "protocol error")
|
|
187
562
|
const { registry } = this.options
|
|
@@ -192,11 +567,26 @@ export class SpokeHub {
|
|
|
192
567
|
if (message.v !== PROTOCOL_VERSION) {
|
|
193
568
|
return this.bye(ws, `unsupported protocol version ${message.v}; server speaks version ${PROTOCOL_VERSION}`)
|
|
194
569
|
}
|
|
570
|
+
const { launch } = ws.data
|
|
571
|
+
if (launch) {
|
|
572
|
+
if (message.launchID !== launch.launchID) return this.bye(ws, "the hello does not name the launch of this token")
|
|
573
|
+
// One connection per launch: a reconnect (the old socket may be half-open) replaces the old one.
|
|
574
|
+
const old = registry.launchConn(launch.launchID)
|
|
575
|
+
if (old !== undefined) {
|
|
576
|
+
registry.disconnect(old)
|
|
577
|
+
const oldWs = this.sockets.get(old)
|
|
578
|
+
if (oldWs) this.bye(oldWs, "replaced by a new connection of this launch", CLOSE_REPLACED)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
195
581
|
clearTimeout(ws.data.helloTimer)
|
|
196
582
|
ws.data.ready = true
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
583
|
+
ws.data.role = "spoke"
|
|
584
|
+
if (!isOpenCodeHello(message)) ws.data.checkedHarness = helloHarness(message).harness
|
|
585
|
+
registry.connect(connID, { id: ws.data.machineID, name: ws.data.machineName }, message, launch ? { launchID: launch.launchID } : {})
|
|
586
|
+
this.registerConn(ws)
|
|
587
|
+
void this.options.store.touchMachine(ws.data.machineID)
|
|
588
|
+
// Older spokes ignore `features`; newer ones send a request a feature adds only when it is listed here.
|
|
589
|
+
this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL, features: [...HUB_FEATURES] })
|
|
200
590
|
return
|
|
201
591
|
}
|
|
202
592
|
|
|
@@ -208,26 +598,94 @@ export class SpokeHub {
|
|
|
208
598
|
return this.resendConversations(connID)
|
|
209
599
|
case "terminals":
|
|
210
600
|
return registry.setTerminals(connID, message)
|
|
211
|
-
case "event":
|
|
601
|
+
case "event": {
|
|
602
|
+
const problem = ws.data.checkedHarness === undefined ? undefined : harnessEventProblem(message.event)
|
|
603
|
+
if (problem) {
|
|
604
|
+
// Logged for the first few and then every 100th, so a misbehaving spoke cannot flood the log.
|
|
605
|
+
const dropped = (ws.data.droppedEvents = (ws.data.droppedEvents ?? 0) + 1)
|
|
606
|
+
if (dropped <= 5 || dropped % 100 === 0) {
|
|
607
|
+
const type = typeof message.event.type === "string" ? message.event.type.slice(0, 100) : "?"
|
|
608
|
+
console.warn(`[hub] dropped a ${ws.data.checkedHarness} spoke's ${type} event (${connID}, ${dropped} so far): ${problem}`)
|
|
609
|
+
}
|
|
610
|
+
return
|
|
611
|
+
}
|
|
212
612
|
return registry.observe(connID, message.event)
|
|
613
|
+
}
|
|
213
614
|
case "req":
|
|
214
615
|
void this.answer(ws, message)
|
|
215
616
|
return
|
|
216
|
-
case "res":
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
clearTimeout(pending.timer)
|
|
221
|
-
if (message.ok) pending.resolve(message.result)
|
|
222
|
-
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
|
|
617
|
+
case "res":
|
|
618
|
+
return this.settle(connID, message)
|
|
619
|
+
case "bye":
|
|
620
|
+
ws.close(1000, "bye")
|
|
223
621
|
return
|
|
224
|
-
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** A frame from a node (or a node hello). */
|
|
626
|
+
private nodeMessage(ws: Socket, raw: string | Buffer): void {
|
|
627
|
+
const message = parseNodeMessage(raw)
|
|
628
|
+
if (!message) return this.bye(ws, "protocol error")
|
|
629
|
+
const { nodes } = this.options
|
|
630
|
+
const { connID } = ws.data
|
|
631
|
+
if (!ws.data.ready) {
|
|
632
|
+
if (message.t !== "hello") return this.bye(ws, "protocol error")
|
|
633
|
+
return this.nodeHello(ws, message)
|
|
634
|
+
}
|
|
635
|
+
switch (message.t) {
|
|
636
|
+
case "hello":
|
|
637
|
+
return this.bye(ws, "protocol error")
|
|
638
|
+
case "capacity":
|
|
639
|
+
return nodes.setCapacity(connID, message)
|
|
640
|
+
case "progress":
|
|
641
|
+
return nodes.progress(connID, message)
|
|
642
|
+
case "res":
|
|
643
|
+
return this.settle(connID, message)
|
|
225
644
|
case "bye":
|
|
226
645
|
ws.close(1000, "bye")
|
|
227
646
|
return
|
|
228
647
|
}
|
|
229
648
|
}
|
|
230
649
|
|
|
650
|
+
private nodeHello(ws: Socket, hello: NodeHello): void {
|
|
651
|
+
if (hello.v !== PROTOCOL_VERSION) return this.bye(ws, `unsupported protocol version ${hello.v}; server speaks version ${PROTOCOL_VERSION}`)
|
|
652
|
+
const { nodes } = this.options
|
|
653
|
+
// This pod's node of the machine only: a stale entry of another pod expires with its slice.
|
|
654
|
+
const existing = nodes.localForMachine(ws.data.machineID)
|
|
655
|
+
if (existing) {
|
|
656
|
+
// One node per machine. The same daemon run reconnecting (its old socket may be half-open) replaces it.
|
|
657
|
+
if (existing.instanceID !== hello.instanceID) return this.bye(ws, "another node is already connected for this machine")
|
|
658
|
+
nodes.disconnect(existing.connID)
|
|
659
|
+
const old = this.sockets.get(existing.connID)
|
|
660
|
+
if (old) this.bye(old, "replaced by a new connection of this node", CLOSE_REPLACED)
|
|
661
|
+
}
|
|
662
|
+
clearTimeout(ws.data.helloTimer)
|
|
663
|
+
ws.data.ready = true
|
|
664
|
+
ws.data.role = NODE_ROLE
|
|
665
|
+
nodes.connect(ws.data.connID, { id: ws.data.machineID, name: ws.data.machineName }, hello)
|
|
666
|
+
this.registerConn(ws)
|
|
667
|
+
void this.options.store.touchMachine(ws.data.machineID)
|
|
668
|
+
this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL, features: [...HUB_FEATURES] })
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Resolves or rejects the pending request a `res` frame answers. */
|
|
672
|
+
private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string }): void {
|
|
673
|
+
const pending = this.pending.get(message.id)
|
|
674
|
+
if (!pending || pending.connID !== connID) return
|
|
675
|
+
this.pending.delete(message.id)
|
|
676
|
+
clearTimeout(pending.timer)
|
|
677
|
+
// Draining: once the result is handed on, this connection may get its bye.
|
|
678
|
+
queueMicrotask(() => this.released(connID))
|
|
679
|
+
if (pending.node) {
|
|
680
|
+
if (!message.ok) return pending.reject(new NodeError(message.error || `The node's ${pending.op} request failed`))
|
|
681
|
+
const problem = nodeResultProblem(pending.op as NodeOp, message.result)
|
|
682
|
+
if (problem) return pending.reject(new NodeError(`The node sent an invalid ${pending.op} result: ${problem}`))
|
|
683
|
+
return pending.resolve(message.result)
|
|
684
|
+
}
|
|
685
|
+
if (message.ok) pending.resolve(message.result)
|
|
686
|
+
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
|
|
687
|
+
}
|
|
688
|
+
|
|
231
689
|
private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
|
|
232
690
|
try {
|
|
233
691
|
const result = await this.handle(ws, frame.req)
|
|
@@ -246,6 +704,8 @@ export class SpokeHub {
|
|
|
246
704
|
const { auth, publicURL } = this.options
|
|
247
705
|
const owner = this.ownerOf(ws.data.machineID)
|
|
248
706
|
if (!owner) throw new Error("This machine is no longer enrolled")
|
|
707
|
+
// A sandboxed harness never gets credentials for the owner's devices or console.
|
|
708
|
+
if (ws.data.launch && (req.op === "pairCode" || req.op === "openLink")) throw new Error("Not available to a launch-bound session")
|
|
249
709
|
switch (req.op) {
|
|
250
710
|
case "pairCode": {
|
|
251
711
|
const { code, expiresAt } = auth.issuePairCode(owner)
|
|
@@ -257,7 +717,7 @@ export class SpokeHub {
|
|
|
257
717
|
if (tenant !== undefined && tenant !== owner) throw new Error("Unknown session")
|
|
258
718
|
this.options.broadcast({ type: "handoff", sessionID: req.sessionID }, owner)
|
|
259
719
|
const title = (tenant && this.options.registry.status(req.sessionID)?.[0]?.title) || req.sessionID
|
|
260
|
-
return { notified: await this.options.notifier.notify("handoff", req.sessionID, title, undefined,
|
|
720
|
+
return { notified: await this.options.notifier.notify("handoff", req.sessionID, title, undefined, owner) }
|
|
261
721
|
}
|
|
262
722
|
case "openLink": {
|
|
263
723
|
const { code } = auth.issueLoginCode(owner)
|
|
@@ -278,15 +738,28 @@ export class SpokeHub {
|
|
|
278
738
|
|
|
279
739
|
private closed(ws: Socket): void {
|
|
280
740
|
clearTimeout(ws.data.helloTimer)
|
|
741
|
+
clearTimeout(ws.data.expiryTimer)
|
|
281
742
|
const { connID } = ws.data
|
|
282
743
|
this.sockets.delete(connID)
|
|
283
744
|
for (const [id, pending] of this.pending) {
|
|
284
745
|
if (pending.connID !== connID) continue
|
|
285
746
|
this.pending.delete(id)
|
|
286
747
|
clearTimeout(pending.timer)
|
|
287
|
-
pending.reject(new SpokeError("OpenCode process disconnected"))
|
|
748
|
+
pending.reject(pending.node ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected"))
|
|
288
749
|
}
|
|
289
|
-
if (ws.data.ready)
|
|
750
|
+
if (!ws.data.ready) return
|
|
751
|
+
this.options.cluster?.directory.unregister(connID).catch((cause) => {
|
|
752
|
+
console.error(`[hub] directory entry not removed: ${cause instanceof Error ? cause.message : cause}`)
|
|
753
|
+
})
|
|
754
|
+
if (ws.data.role === NODE_ROLE) this.options.nodes.disconnect(connID)
|
|
755
|
+
else this.options.registry.disconnect(connID)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Cluster mode: records a ready connection in the directory, so other pods route its ops here. */
|
|
759
|
+
private registerConn(ws: Socket): void {
|
|
760
|
+
this.options.cluster?.directory.register(ws.data.connID, ws.data.machineID).catch((cause) => {
|
|
761
|
+
console.error(`[hub] directory entry not written: ${cause instanceof Error ? cause.message : cause}`)
|
|
762
|
+
})
|
|
290
763
|
}
|
|
291
764
|
|
|
292
765
|
private bye(ws: Socket, reason: string, code = CLOSE_PROTOCOL): void {
|
|
@@ -294,7 +767,12 @@ export class SpokeHub {
|
|
|
294
767
|
ws.close(code, reason)
|
|
295
768
|
}
|
|
296
769
|
|
|
297
|
-
private send(ws: Socket, message: ServerMessage): void {
|
|
770
|
+
private send(ws: Socket, message: ServerMessage | NodeServerMessage): void {
|
|
298
771
|
ws.send(encode(message))
|
|
299
772
|
}
|
|
300
773
|
}
|
|
774
|
+
|
|
775
|
+
/** What a request to a connection that is gone rejects with. */
|
|
776
|
+
function offlineError(kind: RpcCall["kind"]): Error {
|
|
777
|
+
return kind === "node" ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected")
|
|
778
|
+
}
|