@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// StateStore in files under <stateDir>/agent/ (the single-process hub's durable agent data; a cluster uses
|
|
2
|
+
// Postgres). One JSON file per user and collection (users/<user>/<collection>.json) and one per claim namespace
|
|
3
|
+
// (claims/<namespace>.json), loaded on first use and then served from memory. Changed files are written once
|
|
4
|
+
// `debounceMs` after the first change, or at once by flush() (the hub flushes on stop).
|
|
5
|
+
// Files are written atomically (temp file + rename) with mode 0600, folders 0700. Ids and names become path
|
|
6
|
+
// segments only when they are short lowercase [a-z0-9_-] (case-insensitive file systems), otherwise their
|
|
7
|
+
// SHA-256; each file repeats the ids it belongs to, so a file is never read for the wrong one. Hubs from before
|
|
8
|
+
// this store ignore the folder.
|
|
9
|
+
import { createHash } from "node:crypto"
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
import { realTimers } from "./memory-bus"
|
|
13
|
+
import type { Claim, ClaimResult, Clock, Data, Entry, StateStore, Timers, Versioned, WriteOptions, WriteResult } from "./types"
|
|
14
|
+
|
|
15
|
+
/** The folder under the state directory. */
|
|
16
|
+
export const AGENT_STATE_DIR = "agent"
|
|
17
|
+
/** Default delay between a change and its write. */
|
|
18
|
+
export const FILE_STATE_DEBOUNCE_MS = 250
|
|
19
|
+
|
|
20
|
+
export interface FileStateStoreOptions {
|
|
21
|
+
/** The hub's state directory; data goes to <stateDir>/agent/. */
|
|
22
|
+
readonly stateDir: string
|
|
23
|
+
readonly now?: Clock
|
|
24
|
+
/** Timers for the debounced writes (default real, unref'd timers). */
|
|
25
|
+
readonly timers?: Timers
|
|
26
|
+
/** Delay between a change and its write (default FILE_STATE_DEBOUNCE_MS). */
|
|
27
|
+
readonly debounceMs?: number
|
|
28
|
+
/** Reports a failed debounced write (default console.warn); the change is kept for the next write. */
|
|
29
|
+
readonly warn?: (message: string) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Stored {
|
|
33
|
+
value: Data
|
|
34
|
+
version: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface StoredClaim {
|
|
38
|
+
owner: string
|
|
39
|
+
at: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A loaded file: the ids it belongs to and its items by key. */
|
|
43
|
+
interface Doc<T> {
|
|
44
|
+
readonly file: string
|
|
45
|
+
readonly ids: Readonly<Record<string, string>>
|
|
46
|
+
readonly items: Map<string, T>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SAFE_SEGMENT = /^[a-z0-9_-]{1,64}$/
|
|
50
|
+
|
|
51
|
+
/** A file-system-safe, case-insensitive-safe path segment for an id or name. */
|
|
52
|
+
const segment = (name: string): string => (SAFE_SEGMENT.test(name) ? name : `~${createHash("sha256").update(name).digest("hex")}`)
|
|
53
|
+
|
|
54
|
+
export class FileStateStore implements StateStore {
|
|
55
|
+
private readonly now: Clock
|
|
56
|
+
private readonly root: string
|
|
57
|
+
private readonly timers: Timers
|
|
58
|
+
private readonly debounceMs: number
|
|
59
|
+
private readonly warn: (message: string) => void
|
|
60
|
+
/** The pending debounced write. */
|
|
61
|
+
private timer: unknown
|
|
62
|
+
/** file → loaded document. */
|
|
63
|
+
private readonly docs = new Map<string, Doc<unknown>>()
|
|
64
|
+
private readonly dirty = new Set<Doc<unknown>>()
|
|
65
|
+
/** Folders already created and restricted. */
|
|
66
|
+
private readonly folders = new Set<string>()
|
|
67
|
+
|
|
68
|
+
constructor(options: FileStateStoreOptions) {
|
|
69
|
+
this.now = options.now ?? Date.now
|
|
70
|
+
this.root = path.join(options.stateDir, AGENT_STATE_DIR)
|
|
71
|
+
this.timers = options.timers ?? realTimers
|
|
72
|
+
this.debounceMs = options.debounceMs ?? FILE_STATE_DEBOUNCE_MS
|
|
73
|
+
this.warn = options.warn ?? console.warn
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined> {
|
|
77
|
+
const stored = this.entries(userID, collection).items.get(key)
|
|
78
|
+
return stored && { value: structuredClone(stored.value) as T, version: stored.version }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async put<T = Data>(userID: string, collection: string, key: string, value: T, options: WriteOptions = {}): Promise<WriteResult> {
|
|
82
|
+
const doc = this.entries(userID, collection)
|
|
83
|
+
const current = doc.items.get(key)?.version ?? 0
|
|
84
|
+
if (options.ifVersion !== undefined && options.ifVersion !== current) return { ok: false, version: current }
|
|
85
|
+
const version = current + 1
|
|
86
|
+
doc.items.set(key, { value: structuredClone(value), version })
|
|
87
|
+
this.changed(doc)
|
|
88
|
+
return { ok: true, version }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async delete(userID: string, collection: string, key: string, options: WriteOptions = {}): Promise<WriteResult> {
|
|
92
|
+
const doc = this.entries(userID, collection)
|
|
93
|
+
const current = doc.items.get(key)?.version ?? 0
|
|
94
|
+
if (options.ifVersion !== undefined && options.ifVersion !== current) return { ok: false, version: current }
|
|
95
|
+
if (doc.items.delete(key)) this.changed(doc)
|
|
96
|
+
return { ok: true, version: 0 }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]> {
|
|
100
|
+
return [...this.entries(userID, collection).items.entries()]
|
|
101
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
102
|
+
.map(([key, stored]) => ({ key, value: structuredClone(stored.value) as T, version: stored.version }))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async claim(namespace: string, key: string, owner: string): Promise<ClaimResult> {
|
|
106
|
+
const doc = this.claims(namespace)
|
|
107
|
+
const held = doc.items.get(key)
|
|
108
|
+
if (held) return held.owner === owner ? { ok: true, claim: { namespace, key, ...held } } : { ok: false, conflict: { namespace, key, ...held } }
|
|
109
|
+
const claim: Claim = { namespace, key, owner, at: this.now() }
|
|
110
|
+
doc.items.set(key, { owner, at: claim.at })
|
|
111
|
+
this.changed(doc)
|
|
112
|
+
return { ok: true, claim }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async release(namespace: string, key: string, owner: string): Promise<boolean> {
|
|
116
|
+
const doc = this.claims(namespace)
|
|
117
|
+
if (doc.items.get(key)?.owner !== owner) return false
|
|
118
|
+
doc.items.delete(key)
|
|
119
|
+
this.changed(doc)
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async claimOf(namespace: string, key: string): Promise<Claim | undefined> {
|
|
124
|
+
const held = this.claims(namespace).items.get(key)
|
|
125
|
+
return held && { namespace, key, ...held }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Writes every changed file now (rejects when one cannot be written; it stays changed). */
|
|
129
|
+
async flush(): Promise<void> {
|
|
130
|
+
this.writeChanged()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private writeChanged(): void {
|
|
134
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
135
|
+
this.timer = undefined
|
|
136
|
+
for (const doc of [...this.dirty]) {
|
|
137
|
+
this.write(doc)
|
|
138
|
+
this.dirty.delete(doc)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private entries(userID: string, collection: string): Doc<Stored> {
|
|
143
|
+
const file = path.join(this.root, "users", segment(userID), `${segment(collection)}.json`)
|
|
144
|
+
return this.doc<Stored>(file, { userID, collection }, (item) => {
|
|
145
|
+
const { value, version } = item
|
|
146
|
+
if (!Number.isSafeInteger(version) || (version as number) < 1) return undefined
|
|
147
|
+
return { value, version: version as number }
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private claims(namespace: string): Doc<StoredClaim> {
|
|
152
|
+
const file = path.join(this.root, "claims", `${segment(namespace)}.json`)
|
|
153
|
+
return this.doc<StoredClaim>(file, { namespace }, (item) => {
|
|
154
|
+
const { owner, at } = item
|
|
155
|
+
if (typeof owner !== "string" || typeof at !== "number") return undefined
|
|
156
|
+
return { owner, at }
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The loaded document for a file, reading it on first use. */
|
|
161
|
+
private doc<T>(file: string, ids: Record<string, string>, parse: (item: Record<string, unknown>) => T | undefined): Doc<T> {
|
|
162
|
+
const loaded = this.docs.get(file) as Doc<T> | undefined
|
|
163
|
+
if (loaded) {
|
|
164
|
+
for (const [name, id] of Object.entries(ids)) if (loaded.ids[name] !== id) throw new Error(`${file} belongs to another ${name}`)
|
|
165
|
+
return loaded
|
|
166
|
+
}
|
|
167
|
+
const doc: Doc<T> = { file, ids, items: read(file, ids, parse) }
|
|
168
|
+
this.docs.set(file, doc as Doc<unknown>)
|
|
169
|
+
return doc
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private changed(doc: Doc<unknown>): void {
|
|
173
|
+
this.dirty.add(doc)
|
|
174
|
+
if (this.timer !== undefined) return
|
|
175
|
+
this.timer = this.timers.set(() => {
|
|
176
|
+
this.timer = undefined
|
|
177
|
+
try {
|
|
178
|
+
this.writeChanged()
|
|
179
|
+
} catch (cause) {
|
|
180
|
+
this.warn(`[hub] cannot write agent state under ${this.root}: ${(cause as Error).message}; retrying on the next change`)
|
|
181
|
+
}
|
|
182
|
+
}, this.debounceMs)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private write(doc: Doc<unknown>): void {
|
|
186
|
+
if (doc.items.size === 0) {
|
|
187
|
+
rmSync(doc.file, { force: true })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
this.folder(path.dirname(doc.file))
|
|
191
|
+
const items = [...doc.items.entries()].map(([key, item]) => ({ key, ...(item as object) }))
|
|
192
|
+
const tmp = `${doc.file}.${process.pid}.tmp`
|
|
193
|
+
writeFileSync(tmp, `${JSON.stringify({ ...doc.ids, items })}\n`, { mode: 0o600 })
|
|
194
|
+
chmodSync(tmp, 0o600)
|
|
195
|
+
renameSync(tmp, doc.file)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Creates a folder under the root (and the root) restricted to the owner. */
|
|
199
|
+
private folder(dir: string): void {
|
|
200
|
+
if (this.folders.has(dir)) return
|
|
201
|
+
if (dir !== this.root) this.folder(path.dirname(dir))
|
|
202
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
203
|
+
chmodSync(dir, 0o700)
|
|
204
|
+
this.folders.add(dir)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A file's items, or none when it does not exist; throws (naming the file, never its content) when invalid. */
|
|
209
|
+
function read<T>(file: string, ids: Record<string, string>, parse: (item: Record<string, unknown>) => T | undefined): Map<string, T> {
|
|
210
|
+
let text: string
|
|
211
|
+
try {
|
|
212
|
+
text = readFileSync(file, "utf8")
|
|
213
|
+
} catch (cause) {
|
|
214
|
+
if ((cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return new Map()
|
|
215
|
+
throw cause
|
|
216
|
+
}
|
|
217
|
+
const fail = (why: string): never => {
|
|
218
|
+
throw new Error(`Invalid ${file}: ${why}. Fix or move it away.`)
|
|
219
|
+
}
|
|
220
|
+
let raw: unknown
|
|
221
|
+
try {
|
|
222
|
+
raw = JSON.parse(text)
|
|
223
|
+
} catch {
|
|
224
|
+
return fail("not valid JSON")
|
|
225
|
+
}
|
|
226
|
+
if (typeof raw !== "object" || raw === null) return fail("not an object")
|
|
227
|
+
const record = raw as Record<string, unknown>
|
|
228
|
+
for (const [name, id] of Object.entries(ids)) if (record[name] !== id) fail(`${name} does not match`)
|
|
229
|
+
if (!Array.isArray(record.items)) return fail("no items")
|
|
230
|
+
const items = new Map<string, T>()
|
|
231
|
+
for (const entry of record.items as unknown[]) {
|
|
232
|
+
if (typeof entry !== "object" || entry === null) return fail("an item is not an object")
|
|
233
|
+
const { key, ...rest } = entry as Record<string, unknown>
|
|
234
|
+
if (typeof key !== "string") return fail("an item has no key")
|
|
235
|
+
const item = parse(rest)
|
|
236
|
+
if (item === undefined) return fail("an item is malformed")
|
|
237
|
+
items.set(key, item)
|
|
238
|
+
}
|
|
239
|
+
return items
|
|
240
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The hub's backends (design §9): interfaces in ./types, in-memory implementations for the single-process hub,
|
|
2
|
+
// a file StateStore under <stateDir>/agent, and the cluster bundle (Postgres + Valkey). startCentral creates the bundle its config selects (hubBackends:
|
|
3
|
+
// file, or cluster when HUB_DATABASE_URL/HUB_VALKEY_URL are set) or takes one in its options, exposes it to
|
|
4
|
+
// new code only, and flushes its state on stop.
|
|
5
|
+
import { FileStateStore, type FileStateStoreOptions } from "./file-state"
|
|
6
|
+
import { MemoryBus } from "./memory-bus"
|
|
7
|
+
import { MemoryCatalog } from "./memory-catalog"
|
|
8
|
+
import { MemoryCounters, MemoryLocks } from "./memory-counters"
|
|
9
|
+
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX } from "../agent/events"
|
|
10
|
+
import { MemoryEventLog } from "./memory-events"
|
|
11
|
+
import { MemoryKv } from "./memory-kv"
|
|
12
|
+
import { MemoryStateStore } from "./memory-state"
|
|
13
|
+
import { randomUUID } from "node:crypto"
|
|
14
|
+
import { RedisClient } from "bun"
|
|
15
|
+
import { PostgresStateStore } from "./postgres-state"
|
|
16
|
+
import { ValkeyBus } from "./valkey-bus"
|
|
17
|
+
import { ValkeyCatalog } from "./valkey-catalog"
|
|
18
|
+
import { ValkeyCounters } from "./valkey-counters"
|
|
19
|
+
import { ValkeyEventLog } from "./valkey-events"
|
|
20
|
+
import { ValkeyKv } from "./valkey-kv"
|
|
21
|
+
import { ValkeyLocks } from "./valkey-locks"
|
|
22
|
+
import type { CentralConfig, ClusterConfig } from "../config"
|
|
23
|
+
import type { Backends, Clock, Timers } from "./types"
|
|
24
|
+
|
|
25
|
+
export * from "./types"
|
|
26
|
+
|
|
27
|
+
export interface MemoryBackendsOptions {
|
|
28
|
+
/** The clock every backend reads (default Date.now). */
|
|
29
|
+
readonly now?: Clock
|
|
30
|
+
/** Timers for bus request timeouts (default real, unref'd timers). */
|
|
31
|
+
readonly timers?: Timers
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every backend in process memory, on one clock. */
|
|
35
|
+
export function memoryBackends(options: MemoryBackendsOptions = {}): Backends {
|
|
36
|
+
const now = options.now ?? Date.now
|
|
37
|
+
return {
|
|
38
|
+
state: new MemoryStateStore({ now }),
|
|
39
|
+
bus: new MemoryBus(options.timers ? { timers: options.timers } : {}),
|
|
40
|
+
events: new MemoryEventLog({ now }),
|
|
41
|
+
agentEvents: new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
42
|
+
counters: new MemoryCounters({ now }),
|
|
43
|
+
locks: new MemoryLocks({ now }),
|
|
44
|
+
catalog: new MemoryCatalog({ now }),
|
|
45
|
+
kv: new MemoryKv({ now }),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export interface FileBackendsOptions extends MemoryBackendsOptions, Pick<FileStateStoreOptions, "debounceMs" | "warn"> {
|
|
51
|
+
/** The hub's state directory; the StateStore writes under <stateDir>/agent. */
|
|
52
|
+
readonly stateDir: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The StateStore in files under the state directory, every other backend in process memory, on one clock. */
|
|
56
|
+
export function fileBackends(options: FileBackendsOptions): Backends {
|
|
57
|
+
const { stateDir, debounceMs, warn, ...memory } = options
|
|
58
|
+
const state = new FileStateStore({
|
|
59
|
+
stateDir,
|
|
60
|
+
...(memory.now ? { now: memory.now } : {}),
|
|
61
|
+
...(memory.timers ? { timers: memory.timers } : {}),
|
|
62
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
63
|
+
...(warn ? { warn } : {}),
|
|
64
|
+
})
|
|
65
|
+
return { ...memoryBackends(memory), state }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ClusterBackendsOptions extends MemoryBackendsOptions {
|
|
69
|
+
/** HUB_DATABASE_URL / HUB_VALKEY_URL and the pod id (see ../config). */
|
|
70
|
+
readonly cluster: ClusterConfig
|
|
71
|
+
/** Opens the Valkey client kv, counters, locks, events and catalog share (default: Bun.RedisClient; tests). */
|
|
72
|
+
readonly valkeyClient?: (url: string) => RedisClient
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* State in Postgres, everything else in Valkey (design §9). Built synchronously, nothing connects until first
|
|
77
|
+
* use: the Postgres pool checks the schema then (it never migrates: `hub-server migrate` does), and one Valkey
|
|
78
|
+
* client, opened on the first command, serves kv, counters, locks, events and catalog; the bus opens its own
|
|
79
|
+
* (pub/sub needs dedicated connections). close() closes them all. URLs are never put in a message.
|
|
80
|
+
*/
|
|
81
|
+
export function clusterBackends(options: ClusterBackendsOptions): Backends {
|
|
82
|
+
const { cluster } = options
|
|
83
|
+
const now = options.now
|
|
84
|
+
const open = options.valkeyClient ?? ((url: string) => new RedisClient(url))
|
|
85
|
+
let client: RedisClient | undefined
|
|
86
|
+
// One lazily opened client behind the shape the Valkey backends use (send / close).
|
|
87
|
+
const shared = {
|
|
88
|
+
send: (command: string, args: string[]) => (client ??= open(cluster.valkeyURL)).send(command, args),
|
|
89
|
+
close: () => {
|
|
90
|
+
client?.close()
|
|
91
|
+
client = undefined
|
|
92
|
+
},
|
|
93
|
+
} as unknown as RedisClient
|
|
94
|
+
const state = new PostgresStateStore({ url: cluster.databaseURL, ...(now ? { now } : {}) })
|
|
95
|
+
const bus = new ValkeyBus(cluster.valkeyURL, { instanceID: `${cluster.podID}-${randomUUID().slice(0, 8)}`, ...(options.timers ? { timers: options.timers } : {}) })
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
bus,
|
|
99
|
+
events: new ValkeyEventLog(shared, now ? { now } : {}),
|
|
100
|
+
agentEvents: new ValkeyEventLog(shared, { ...(now ? { now } : {}), max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS, prefix: "hub:agent:" }),
|
|
101
|
+
counters: new ValkeyCounters(shared),
|
|
102
|
+
locks: new ValkeyLocks(shared, now ? { now } : {}),
|
|
103
|
+
catalog: new ValkeyCatalog(shared, now ? { now } : {}),
|
|
104
|
+
kv: new ValkeyKv(shared),
|
|
105
|
+
async close() {
|
|
106
|
+
bus.close()
|
|
107
|
+
shared.close()
|
|
108
|
+
await state.close()
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The hub's bundle for its config: the cluster one when cluster mode is configured, else the file one. */
|
|
114
|
+
export function hubBackends(config: Pick<CentralConfig, "stateDir" | "cluster">, options: MemoryBackendsOptions = {}): Backends {
|
|
115
|
+
return config.cluster ? clusterBackends({ ...options, cluster: config.cluster }) : fileBackends({ ...options, stateDir: config.stateDir })
|
|
116
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Bus in process memory (a cluster uses Valkey/Redis pub/sub). Messages are copied, like a network hop, so
|
|
2
|
+
// publisher and subscribers never share objects. publish resolves once every subscriber has handled the message
|
|
3
|
+
// here; callers must not rely on that (a networked bus resolves on send).
|
|
4
|
+
import { BusNoResponderError, BusRemoteError, BusTimeoutError, type Bus, type BusHandler, type BusResponder, type Data, type RequestOptions, type Timers, type Unsubscribe } from "./types"
|
|
5
|
+
|
|
6
|
+
/** setTimeout-based timers that never keep the process alive. */
|
|
7
|
+
export const realTimers: Timers = {
|
|
8
|
+
set: (fn, ms) => {
|
|
9
|
+
const handle = setTimeout(fn, ms)
|
|
10
|
+
;(handle as { unref?: () => void }).unref?.()
|
|
11
|
+
return handle
|
|
12
|
+
},
|
|
13
|
+
clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MemoryBusOptions {
|
|
17
|
+
/** Request timeouts (injectable for tests). */
|
|
18
|
+
readonly timers?: Timers
|
|
19
|
+
/** Called with a subscriber's error (default: logged). */
|
|
20
|
+
readonly onError?: (error: unknown, topic: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MemoryBus implements Bus {
|
|
24
|
+
private readonly timers: Timers
|
|
25
|
+
private readonly onError: (error: unknown, topic: string) => void
|
|
26
|
+
private readonly subscribers = new Map<string, Set<BusHandler>>()
|
|
27
|
+
private readonly responders = new Map<string, BusResponder>()
|
|
28
|
+
|
|
29
|
+
constructor(options: MemoryBusOptions = {}) {
|
|
30
|
+
this.timers = options.timers ?? realTimers
|
|
31
|
+
this.onError = options.onError ?? ((error, topic) => console.error(`hub: bus subscriber on ${topic} failed:`, error))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async publish(topic: string, message: Data): Promise<number> {
|
|
35
|
+
const handlers = [...(this.subscribers.get(topic) ?? [])]
|
|
36
|
+
await Promise.all(
|
|
37
|
+
handlers.map(async (handler) => {
|
|
38
|
+
try {
|
|
39
|
+
await handler(structuredClone(message), topic)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
this.onError(error, topic)
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
return handlers.length
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async subscribe(topic: string, handler: BusHandler): Promise<Unsubscribe> {
|
|
49
|
+
let handlers = this.subscribers.get(topic)
|
|
50
|
+
if (!handlers) this.subscribers.set(topic, (handlers = new Set()))
|
|
51
|
+
// Wrapped so subscribing one function twice gives two independent subscriptions.
|
|
52
|
+
const subscription: BusHandler = (message, t) => handler(message, t)
|
|
53
|
+
handlers.add(subscription)
|
|
54
|
+
return async () => {
|
|
55
|
+
const current = this.subscribers.get(topic)
|
|
56
|
+
current?.delete(subscription)
|
|
57
|
+
if (current?.size === 0) this.subscribers.delete(topic)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async serve(topic: string, responder: BusResponder): Promise<Unsubscribe> {
|
|
62
|
+
if (this.responders.has(topic)) throw new Error(`already serving ${topic}`)
|
|
63
|
+
this.responders.set(topic, responder)
|
|
64
|
+
return async () => {
|
|
65
|
+
if (this.responders.get(topic) === responder) this.responders.delete(topic)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
request<T = Data>(topic: string, message: Data, options: RequestOptions): Promise<T> {
|
|
70
|
+
const responder = this.responders.get(topic)
|
|
71
|
+
if (!responder) return Promise.reject(new BusNoResponderError(topic))
|
|
72
|
+
return new Promise<T>((resolve, reject) => {
|
|
73
|
+
let settled = false
|
|
74
|
+
const timer = this.timers.set(() => {
|
|
75
|
+
if (settled) return
|
|
76
|
+
settled = true
|
|
77
|
+
reject(new BusTimeoutError(topic, options.timeoutMs))
|
|
78
|
+
}, options.timeoutMs)
|
|
79
|
+
const settle = (fn: () => void) => {
|
|
80
|
+
if (settled) return
|
|
81
|
+
settled = true
|
|
82
|
+
this.timers.clear(timer)
|
|
83
|
+
fn()
|
|
84
|
+
}
|
|
85
|
+
Promise.resolve()
|
|
86
|
+
.then(() => responder(structuredClone(message), topic))
|
|
87
|
+
.then(
|
|
88
|
+
(reply) => settle(() => resolve(structuredClone(reply) as T)),
|
|
89
|
+
(error) => settle(() => reject(new BusRemoteError(topic, error instanceof Error ? error.message : String(error)))),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Catalog in process memory (a cluster uses one Redis hash per user). Each connection's report of a session is
|
|
2
|
+
// kept apart, keyed by (sessionID, connID), and merged when read, so every process writes only its own reports.
|
|
3
|
+
// Reports with a ttl expire lazily against the clock.
|
|
4
|
+
import type { Catalog, CatalogEntry, CatalogSession, CatalogValue, Clock } from "./types"
|
|
5
|
+
|
|
6
|
+
/** Combines a session's reports (oldest first) into one value. */
|
|
7
|
+
export type CatalogMerge = (entries: CatalogEntry[]) => CatalogValue
|
|
8
|
+
|
|
9
|
+
/** Default merge: a shallow merge in report order, so the newest report wins per field. */
|
|
10
|
+
export const mergeNewestWins: CatalogMerge = (entries) => Object.assign({}, ...entries.map((entry) => entry.value))
|
|
11
|
+
|
|
12
|
+
interface Report extends CatalogEntry {
|
|
13
|
+
readonly expiresAt?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const reportKey = (sessionID: string, connID: string) => `${sessionID}\u0000${connID}`
|
|
17
|
+
|
|
18
|
+
export class MemoryCatalog implements Catalog {
|
|
19
|
+
private readonly now: Clock
|
|
20
|
+
private readonly merge: CatalogMerge
|
|
21
|
+
/** userID → reportKey → report, in report order (a new report moves to the end). */
|
|
22
|
+
private readonly users = new Map<string, Map<string, Report>>()
|
|
23
|
+
|
|
24
|
+
constructor(options: { now?: Clock; merge?: CatalogMerge } = {}) {
|
|
25
|
+
this.now = options.now ?? Date.now
|
|
26
|
+
this.merge = options.merge ?? mergeNewestWins
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async set(userID: string, sessionID: string, connID: string, value: CatalogValue, options: { readonly ttlMs?: number } = {}): Promise<void> {
|
|
30
|
+
let reports = this.users.get(userID)
|
|
31
|
+
if (!reports) this.users.set(userID, (reports = new Map()))
|
|
32
|
+
const at = this.now()
|
|
33
|
+
const key = reportKey(sessionID, connID)
|
|
34
|
+
reports.delete(key)
|
|
35
|
+
reports.set(key, { sessionID, connID, value: structuredClone(value), at, ...(options.ttlMs !== undefined ? { expiresAt: at + options.ttlMs } : {}) })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async delete(userID: string, sessionID: string, connID?: string): Promise<void> {
|
|
39
|
+
this.remove(userID, (report) => report.sessionID === sessionID && (connID === undefined || report.connID === connID))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async deleteConn(userID: string, connID: string): Promise<void> {
|
|
43
|
+
this.remove(userID, (report) => report.connID === connID)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async get(userID: string, sessionID: string): Promise<CatalogSession | undefined> {
|
|
47
|
+
const reports = this.live(userID).filter((report) => report.sessionID === sessionID)
|
|
48
|
+
return reports.length ? this.session(sessionID, reports) : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async list(userID: string): Promise<CatalogSession[]> {
|
|
52
|
+
const bySession = new Map<string, CatalogEntry[]>()
|
|
53
|
+
for (const report of this.live(userID)) {
|
|
54
|
+
const reports = bySession.get(report.sessionID)
|
|
55
|
+
if (reports) reports.push(report)
|
|
56
|
+
else bySession.set(report.sessionID, [report])
|
|
57
|
+
}
|
|
58
|
+
return [...bySession.keys()].sort().map((sessionID) => this.session(sessionID, bySession.get(sessionID)!))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async entries(userID: string): Promise<CatalogEntry[]> {
|
|
62
|
+
return this.live(userID).map(({ sessionID, connID, value, at }) => ({ sessionID, connID, value: structuredClone(value), at }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The user's unexpired reports in report order (expired ones are dropped). */
|
|
66
|
+
private live(userID: string): Report[] {
|
|
67
|
+
const reports = this.users.get(userID)
|
|
68
|
+
if (!reports) return []
|
|
69
|
+
const t = this.now()
|
|
70
|
+
for (const [key, report] of reports) if (report.expiresAt !== undefined && report.expiresAt <= t) reports.delete(key)
|
|
71
|
+
if (!reports.size) this.users.delete(userID)
|
|
72
|
+
return [...reports.values()]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private session(sessionID: string, reports: CatalogEntry[]): CatalogSession {
|
|
76
|
+
return {
|
|
77
|
+
sessionID,
|
|
78
|
+
value: structuredClone(this.merge(reports)),
|
|
79
|
+
connIDs: reports.map((report) => report.connID),
|
|
80
|
+
at: Math.max(...reports.map((report) => report.at)),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private remove(userID: string, match: (report: Report) => boolean): void {
|
|
85
|
+
const reports = this.users.get(userID)
|
|
86
|
+
if (!reports) return
|
|
87
|
+
for (const [key, report] of reports) if (match(report)) reports.delete(key)
|
|
88
|
+
if (!reports.size) this.users.delete(userID)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Counters and Locks in process memory (a cluster uses Redis INCR + PEXPIRE NX and SET NX PX). Expiry is
|
|
2
|
+
// evaluated lazily against the clock: no timers. Expired keys are dropped when touched and in periodic sweeps,
|
|
3
|
+
// so keys that are never read again do not pile up.
|
|
4
|
+
import type { Clock, Counters, Lease, Locks } from "./types"
|
|
5
|
+
|
|
6
|
+
/** Writes between sweeps of expired keys. */
|
|
7
|
+
const SWEEP_EVERY = 1000
|
|
8
|
+
|
|
9
|
+
/** A map whose entries expire at `expiresAt`; reads drop expired entries. */
|
|
10
|
+
export class ExpiringMap<V extends { readonly expiresAt: number }> {
|
|
11
|
+
private readonly entries = new Map<string, V>()
|
|
12
|
+
private writes = 0
|
|
13
|
+
|
|
14
|
+
constructor(private readonly now: Clock) {}
|
|
15
|
+
|
|
16
|
+
get(key: string): V | undefined {
|
|
17
|
+
const entry = this.entries.get(key)
|
|
18
|
+
if (entry && entry.expiresAt <= this.now()) {
|
|
19
|
+
this.entries.delete(key)
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
return entry
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
set(key: string, value: V): void {
|
|
26
|
+
this.entries.set(key, value)
|
|
27
|
+
if (++this.writes % SWEEP_EVERY === 0) this.sweep()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
delete(key: string): void {
|
|
31
|
+
this.entries.delete(key)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private sweep(): void {
|
|
35
|
+
const t = this.now()
|
|
36
|
+
for (const [key, entry] of this.entries) if (entry.expiresAt <= t) this.entries.delete(key)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class MemoryCounters implements Counters {
|
|
41
|
+
private readonly now: Clock
|
|
42
|
+
private readonly counts: ExpiringMap<{ count: number; expiresAt: number }>
|
|
43
|
+
|
|
44
|
+
constructor(options: { now?: Clock } = {}) {
|
|
45
|
+
this.now = options.now ?? Date.now
|
|
46
|
+
this.counts = new ExpiringMap(this.now)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async incr(key: string, ttlMs: number): Promise<number> {
|
|
50
|
+
const current = this.counts.get(key)
|
|
51
|
+
const count = (current?.count ?? 0) + 1
|
|
52
|
+
this.counts.set(key, { count, expiresAt: current?.expiresAt ?? this.now() + ttlMs })
|
|
53
|
+
return count
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async get(key: string): Promise<number> {
|
|
57
|
+
return this.counts.get(key)?.count ?? 0
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class MemoryLocks implements Locks {
|
|
62
|
+
private readonly now: Clock
|
|
63
|
+
private readonly leases: ExpiringMap<Lease>
|
|
64
|
+
/** Last fence handed out per key (kept after release so fences only grow). */
|
|
65
|
+
private readonly fences = new Map<string, number>()
|
|
66
|
+
|
|
67
|
+
constructor(options: { now?: Clock } = {}) {
|
|
68
|
+
this.now = options.now ?? Date.now
|
|
69
|
+
this.leases = new ExpiringMap(this.now)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async acquire(key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
|
|
73
|
+
const held = this.leases.get(key)
|
|
74
|
+
if (held) return held.owner === owner ? this.extend(held, ttlMs) : undefined
|
|
75
|
+
const fence = (this.fences.get(key) ?? 0) + 1
|
|
76
|
+
this.fences.set(key, fence)
|
|
77
|
+
const lease: Lease = { key, owner, fence, expiresAt: this.now() + ttlMs }
|
|
78
|
+
this.leases.set(key, lease)
|
|
79
|
+
return lease
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async renew(key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
|
|
83
|
+
const held = this.leases.get(key)
|
|
84
|
+
return held?.owner === owner ? this.extend(held, ttlMs) : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async release(key: string, owner: string): Promise<boolean> {
|
|
88
|
+
if (this.leases.get(key)?.owner !== owner) return false
|
|
89
|
+
this.leases.delete(key)
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async holder(key: string): Promise<Lease | undefined> {
|
|
94
|
+
return this.leases.get(key)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private extend(lease: Lease, ttlMs: number): Lease {
|
|
98
|
+
const renewed = { ...lease, expiresAt: this.now() + ttlMs }
|
|
99
|
+
this.leases.set(lease.key, renewed)
|
|
100
|
+
return renewed
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// EventLog in process memory (a cluster uses Redis Streams). Same semantics and bounds as the live stream's log
|
|
2
|
+
// (../events-log.ts), which it reuses as its ring; it is a separate instance, the live stream keeps its own.
|
|
3
|
+
import { EventLog as Ring, EVENT_LOG_AGE_MS, EVENT_LOG_MAX } from "../events-log"
|
|
4
|
+
import type { Clock, EventLog, EventMessage, LogEvent, SinceResult } from "./types"
|
|
5
|
+
|
|
6
|
+
export interface MemoryEventLogOptions {
|
|
7
|
+
readonly now?: Clock
|
|
8
|
+
/** Events kept per user (default EVENT_LOG_MAX). */
|
|
9
|
+
readonly max?: number
|
|
10
|
+
/** Events older than this are dropped (default EVENT_LOG_AGE_MS). */
|
|
11
|
+
readonly maxAgeMs?: number
|
|
12
|
+
/** Id prefix; new for every instance by default, so ids from another run are never mistaken for current ones. */
|
|
13
|
+
readonly epoch?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const newEpoch = () => `${Date.now().toString(36)}${crypto.randomUUID().replaceAll("-", "").slice(0, 8)}`
|
|
17
|
+
|
|
18
|
+
export class MemoryEventLog implements EventLog {
|
|
19
|
+
private readonly ring: Ring
|
|
20
|
+
|
|
21
|
+
constructor(options: MemoryEventLogOptions = {}) {
|
|
22
|
+
this.ring = new Ring({
|
|
23
|
+
max: options.max ?? EVENT_LOG_MAX,
|
|
24
|
+
maxAgeMs: options.maxAgeMs ?? EVENT_LOG_AGE_MS,
|
|
25
|
+
now: options.now ?? Date.now,
|
|
26
|
+
epoch: options.epoch ?? newEpoch(),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async append(userID: string, message: EventMessage, options: { readonly exceptDevice?: string } = {}): Promise<string> {
|
|
31
|
+
return this.ring.append(userID, structuredClone(message), options.exceptDevice)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async latest(userID: string): Promise<string> {
|
|
35
|
+
// Opening the user's log lets a client holding this id be covered before the first event.
|
|
36
|
+
this.ring.open(userID)
|
|
37
|
+
return this.ring.latest(userID)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async since(userID: string, lastID: string): Promise<SinceResult> {
|
|
41
|
+
const events = this.ring.since(userID, lastID)
|
|
42
|
+
if (!events) return { resync: true }
|
|
43
|
+
return { events: events.map((event): LogEvent => structuredClone(event)) }
|
|
44
|
+
}
|
|
45
|
+
}
|