@huynle/hub 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. 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,113 @@
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 { MemoryEventLog } from "./memory-events"
10
+ import { MemoryKv } from "./memory-kv"
11
+ import { MemoryStateStore } from "./memory-state"
12
+ import { randomUUID } from "node:crypto"
13
+ import { RedisClient } from "bun"
14
+ import { PostgresStateStore } from "./postgres-state"
15
+ import { ValkeyBus } from "./valkey-bus"
16
+ import { ValkeyCatalog } from "./valkey-catalog"
17
+ import { ValkeyCounters } from "./valkey-counters"
18
+ import { ValkeyEventLog } from "./valkey-events"
19
+ import { ValkeyKv } from "./valkey-kv"
20
+ import { ValkeyLocks } from "./valkey-locks"
21
+ import type { CentralConfig, ClusterConfig } from "../config"
22
+ import type { Backends, Clock, Timers } from "./types"
23
+
24
+ export * from "./types"
25
+
26
+ export interface MemoryBackendsOptions {
27
+ /** The clock every backend reads (default Date.now). */
28
+ readonly now?: Clock
29
+ /** Timers for bus request timeouts (default real, unref'd timers). */
30
+ readonly timers?: Timers
31
+ }
32
+
33
+ /** Every backend in process memory, on one clock. */
34
+ export function memoryBackends(options: MemoryBackendsOptions = {}): Backends {
35
+ const now = options.now ?? Date.now
36
+ return {
37
+ state: new MemoryStateStore({ now }),
38
+ bus: new MemoryBus(options.timers ? { timers: options.timers } : {}),
39
+ events: new MemoryEventLog({ now }),
40
+ counters: new MemoryCounters({ now }),
41
+ locks: new MemoryLocks({ now }),
42
+ catalog: new MemoryCatalog({ now }),
43
+ kv: new MemoryKv({ now }),
44
+ }
45
+ }
46
+
47
+
48
+ export interface FileBackendsOptions extends MemoryBackendsOptions, Pick<FileStateStoreOptions, "debounceMs" | "warn"> {
49
+ /** The hub's state directory; the StateStore writes under <stateDir>/agent. */
50
+ readonly stateDir: string
51
+ }
52
+
53
+ /** The StateStore in files under the state directory, every other backend in process memory, on one clock. */
54
+ export function fileBackends(options: FileBackendsOptions): Backends {
55
+ const { stateDir, debounceMs, warn, ...memory } = options
56
+ const state = new FileStateStore({
57
+ stateDir,
58
+ ...(memory.now ? { now: memory.now } : {}),
59
+ ...(memory.timers ? { timers: memory.timers } : {}),
60
+ ...(debounceMs !== undefined ? { debounceMs } : {}),
61
+ ...(warn ? { warn } : {}),
62
+ })
63
+ return { ...memoryBackends(memory), state }
64
+ }
65
+
66
+ export interface ClusterBackendsOptions extends MemoryBackendsOptions {
67
+ /** HUB_DATABASE_URL / HUB_VALKEY_URL and the pod id (see ../config). */
68
+ readonly cluster: ClusterConfig
69
+ /** Opens the Valkey client kv, counters, locks, events and catalog share (default: Bun.RedisClient; tests). */
70
+ readonly valkeyClient?: (url: string) => RedisClient
71
+ }
72
+
73
+ /**
74
+ * State in Postgres, everything else in Valkey (design §9). Built synchronously, nothing connects until first
75
+ * use: the Postgres pool checks the schema then (it never migrates: `hub-server migrate` does), and one Valkey
76
+ * client, opened on the first command, serves kv, counters, locks, events and catalog; the bus opens its own
77
+ * (pub/sub needs dedicated connections). close() closes them all. URLs are never put in a message.
78
+ */
79
+ export function clusterBackends(options: ClusterBackendsOptions): Backends {
80
+ const { cluster } = options
81
+ const now = options.now
82
+ const open = options.valkeyClient ?? ((url: string) => new RedisClient(url))
83
+ let client: RedisClient | undefined
84
+ // One lazily opened client behind the shape the Valkey backends use (send / close).
85
+ const shared = {
86
+ send: (command: string, args: string[]) => (client ??= open(cluster.valkeyURL)).send(command, args),
87
+ close: () => {
88
+ client?.close()
89
+ client = undefined
90
+ },
91
+ } as unknown as RedisClient
92
+ const state = new PostgresStateStore({ url: cluster.databaseURL, ...(now ? { now } : {}) })
93
+ const bus = new ValkeyBus(cluster.valkeyURL, { instanceID: `${cluster.podID}-${randomUUID().slice(0, 8)}`, ...(options.timers ? { timers: options.timers } : {}) })
94
+ return {
95
+ state,
96
+ bus,
97
+ events: new ValkeyEventLog(shared, now ? { now } : {}),
98
+ counters: new ValkeyCounters(shared),
99
+ locks: new ValkeyLocks(shared, now ? { now } : {}),
100
+ catalog: new ValkeyCatalog(shared, now ? { now } : {}),
101
+ kv: new ValkeyKv(shared),
102
+ async close() {
103
+ bus.close()
104
+ shared.close()
105
+ await state.close()
106
+ },
107
+ }
108
+ }
109
+
110
+ /** The hub's bundle for its config: the cluster one when cluster mode is configured, else the file one. */
111
+ export function hubBackends(config: Pick<CentralConfig, "stateDir" | "cluster">, options: MemoryBackendsOptions = {}): Backends {
112
+ return config.cluster ? clusterBackends({ ...options, cluster: config.cluster }) : fileBackends({ ...options, stateDir: config.stateDir })
113
+ }
@@ -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
+ }