@huynle/hub 0.6.1 → 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,518 @@
1
+ // The Store's shared persistence (cluster mode; decision vkxkqiuf §3, §4): each record is a row in a StateStore
2
+ // (Postgres) under user "_hub", and each pod keeps the whole StateData as a cache for the Store's synchronous reads.
3
+ //
4
+ // Rows (collection/key → value):
5
+ // users/<id>, machines/<id>, devices/<id> the records
6
+ // push/<sha256(endpoint)> push subscriptions
7
+ // settings/vapid | speech | agentsActingPaused hub-wide settings
8
+ // audit/<time>-<seq>-<random> audit entries (sorted by key = by time)
9
+ // machine-tokens/<tokenHash>, device-tokens/<tokenHash> {id}: O(1) token lookups on any pod
10
+ // user-emails/<email> {id}: one user per email, also across pods
11
+ //
12
+ // Mutations run on the cache, then write the rows they touched with the version the cache had (ifVersion); on a
13
+ // conflict the touched rows are re-read and the mutation runs again on fresh data. Records and settings are
14
+ // versioned this way; audit, token and email index rows are inserted with ifVersion 0 (an email row in use means
15
+ // the user exists: the mutation runs again and finds it) and deleted unconditionally. After a write, the pod
16
+ // publishes store.changed {from, rows: [[collection, key, version]]} (never values); other pods re-read those rows.
17
+ // The Bus is at most once (a reconnect loses messages), so the cache is also reloaded every ttlMs, and
18
+ // security-relevant reads (token lookups, epochs, disabled users) re-read their rows first (Store.recheck).
19
+ import { createHash, randomBytes } from "node:crypto"
20
+ import { realTimers } from "./backend/memory-bus"
21
+ import type { Bus, StateStore, Timers, Unsubscribe } from "./backend/types"
22
+ import type { AuditEntry, StateData } from "./store"
23
+
24
+ /** The StateStore "user" the Store's rows live under. */
25
+ export const HUB_ROWS = "_hub"
26
+ /** Bus topic of the Store's row changes. */
27
+ export const STORE_CHANGED_TOPIC = "store.changed"
28
+ /** How often a pod reloads its whole cache (covers store.changed messages lost on a Bus reconnect). */
29
+ export const STORE_CACHE_TTL_MS = 30_000
30
+ /** Attempts of a mutation that keeps hitting version conflicts. */
31
+ const MAX_ATTEMPTS = 20
32
+
33
+ export type Collection = "users" | "machines" | "devices" | "push" | "settings" | "audit" | "machine-tokens" | "device-tokens" | "user-emails"
34
+
35
+ const RECORDS = ["users", "machines", "devices", "push", "settings", "audit"] as const
36
+ const SETTINGS = ["vapid", "speech", "agentsActingPaused"] as const
37
+
38
+ export interface RowRef {
39
+ readonly collection: Collection
40
+ readonly key: string
41
+ }
42
+
43
+ const refID = (ref: RowRef) => `${ref.collection}\u0000${ref.key}`
44
+
45
+ /** Versioned records (ifVersion writes) versus insert-only rows (ifVersion 0, unconditional delete). */
46
+ const versioned = (collection: Collection) => collection === "users" || collection === "machines" || collection === "devices" || collection === "push" || collection === "settings"
47
+
48
+ export const pushKey = (endpoint: string): string => createHash("sha256").update(endpoint).digest("hex")
49
+
50
+ /** What a mutation touched (rows to write); the file Store only needs to know whether it is dirty. */
51
+ export class StoreTx {
52
+ readonly refs = new Map<string, RowRef>()
53
+
54
+ touch(collection: Collection, key: string): void {
55
+ this.refs.set(refID({ collection, key }), { collection, key })
56
+ }
57
+
58
+ get dirty(): boolean {
59
+ return this.refs.size > 0
60
+ }
61
+ }
62
+
63
+ /** A mutation touched rows another pod changed meanwhile, again and again. */
64
+ export class StoreConflictError extends Error {
65
+ constructor() {
66
+ super("The hub state kept changing during this update; try again")
67
+ this.name = "StoreConflictError"
68
+ }
69
+ }
70
+
71
+ export interface SharedStoreOptions {
72
+ readonly state: StateStore
73
+ readonly bus: Bus
74
+ readonly now?: () => number
75
+ /** The TTL reload timer (injectable for tests; default real, unref'd timers). */
76
+ readonly timers?: Timers
77
+ /** Cache reload interval (default STORE_CACHE_TTL_MS). */
78
+ readonly ttlMs?: number
79
+ /** Names this pod in store.changed (default random); its own messages are ignored. */
80
+ readonly instanceID?: string
81
+ readonly warn?: (message: string) => void
82
+ }
83
+
84
+ interface Write {
85
+ readonly ref: RowRef
86
+ readonly value: unknown
87
+ readonly expected: number
88
+ }
89
+
90
+ interface ChangedMessage {
91
+ readonly from: string
92
+ readonly rows: [string, string, number][]
93
+ }
94
+
95
+ const emptyState = (): StateData => ({ machines: [], devices: [], push: [], users: [], audit: [] })
96
+
97
+ const byCreated = (a: { created: number; id: string }, b: { created: number; id: string }) =>
98
+ a.created - b.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
99
+
100
+ export class SharedRows {
101
+ readonly instanceID: string
102
+ private readonly store: StateStore
103
+ private readonly bus: Bus
104
+ private readonly timers: Timers
105
+ private readonly ttlMs: number
106
+ private readonly warn: (message: string) => void
107
+ /** refID → the version the cache holds (versioned rows only). */
108
+ private versions = new Map<string, number>()
109
+ /** Audit entry → its row key. */
110
+ private readonly auditKeys = new WeakMap<AuditEntry, string>()
111
+ private loading?: Promise<void>
112
+ /** The first load finished: mutations run their first attempt synchronously. */
113
+ private loaded = false
114
+ private timer?: unknown
115
+ private unsubscribe?: Unsubscribe
116
+ private closed = false
117
+
118
+ constructor(
119
+ options: SharedStoreOptions,
120
+ private readonly cache: { get(): StateData; set(state: StateData): void },
121
+ ) {
122
+ this.store = options.state
123
+ this.bus = options.bus
124
+ this.timers = options.timers ?? realTimers
125
+ this.ttlMs = options.ttlMs ?? STORE_CACHE_TTL_MS
126
+ this.warn = options.warn ?? ((message) => console.warn(message))
127
+ this.instanceID = options.instanceID ?? randomBytes(8).toString("hex")
128
+ }
129
+
130
+ static emptyState = emptyState
131
+
132
+ /** Subscribes to store.changed and loads the cache, once (a failure is retried by the next call). */
133
+ ready(): Promise<void> {
134
+ this.loading ??= this.start().catch((error: unknown) => {
135
+ this.loading = undefined
136
+ throw error
137
+ })
138
+ return this.loading
139
+ }
140
+
141
+ async close(): Promise<void> {
142
+ this.closed = true
143
+ if (this.timer !== undefined) this.timers.clear(this.timer)
144
+ this.timer = undefined
145
+ const unsubscribe = this.unsubscribe
146
+ this.unsubscribe = undefined
147
+ await unsubscribe?.()
148
+ }
149
+
150
+ private async start(): Promise<void> {
151
+ // Subscribed before loading, so no change between the two is missed.
152
+ this.unsubscribe ??= await this.bus.subscribe(STORE_CHANGED_TOPIC, (message) => this.onChanged(message))
153
+ await this.load()
154
+ this.loaded = true
155
+ this.schedule()
156
+ }
157
+
158
+ private schedule(): void {
159
+ if (this.closed) return
160
+ this.timer = this.timers.set(() => {
161
+ this.timer = undefined
162
+ this.load()
163
+ .catch((error: unknown) => this.warn(`[hub] state cache reload failed: ${(error as Error)?.message ?? error}`))
164
+ .finally(() => this.schedule())
165
+ }, this.ttlMs)
166
+ }
167
+
168
+ /** Reads every record row into a new cache. */
169
+ async load(): Promise<void> {
170
+ const [users, machines, devices, push, settings, audit] = await Promise.all(RECORDS.map((collection) => this.store.list(HUB_ROWS, collection)))
171
+ const versions = new Map<string, number>()
172
+ const note = (collection: Collection, rows: { key: string; version: number }[]) => {
173
+ for (const row of rows) versions.set(refID({ collection, key: row.key }), row.version)
174
+ }
175
+ note("users", users!)
176
+ note("machines", machines!)
177
+ note("devices", devices!)
178
+ note("push", push!)
179
+ note("settings", settings!)
180
+ const state = emptyState()
181
+ state.users = users!.map((row) => row.value as StateData["users"][number]).sort(byCreated)
182
+ state.machines = machines!.map((row) => row.value as StateData["machines"][number]).sort(byCreated)
183
+ state.devices = devices!.map((row) => row.value as StateData["devices"][number]).sort(byCreated)
184
+ state.push = push!
185
+ .map((row) => row.value as StateData["push"][number])
186
+ .sort((a, b) => a.created - b.created || (a.endpoint < b.endpoint ? -1 : 1))
187
+ for (const row of settings!) setSetting(state, row.key, row.value)
188
+ state.audit = audit!.map((row) => {
189
+ const entry = row.value as AuditEntry
190
+ this.auditKeys.set(entry, row.key)
191
+ return entry
192
+ })
193
+ this.versions = versions
194
+ this.cache.set(state)
195
+ }
196
+
197
+ /** A new row key for an audit entry (sorted by time). */
198
+ keyAudit(entry: AuditEntry): string {
199
+ // After every key the cache holds, so entries keep their order across pods when clocks tie or skew a little.
200
+ const last = this.cache.get().audit.reduce<string | undefined>((max, e) => {
201
+ const key = this.auditKeys.get(e)
202
+ return key && (!max || key > max) ? key : max
203
+ }, undefined)
204
+ const key = nextAuditKey(last, entry.at, randomBytes(4).toString("hex"))
205
+ this.auditKeys.set(entry, key)
206
+ return key
207
+ }
208
+
209
+ /**
210
+ * Adds the audit rows the cache lacks (entries other pods wrote that store.changed has not brought yet, or never
211
+ * will after a lost message), so the next keyAudit sorts after every entry written before it on any pod.
212
+ */
213
+ async syncAudit(): Promise<void> {
214
+ await this.ready()
215
+ const rows = await this.store.list<AuditEntry>(HUB_ROWS, "audit")
216
+ const known = new Set(this.cache.get().audit.map((entry) => this.auditKeys.get(entry)))
217
+ for (const row of rows) if (!known.has(row.key)) applyRow(this.cache.get(), { collection: "audit", key: row.key }, row.value, this)
218
+ }
219
+
220
+ auditKey(entry: AuditEntry): string | undefined {
221
+ return this.auditKeys.get(entry)
222
+ }
223
+
224
+ /** Records the row key of an audit entry read from the database. */
225
+ rememberAudit(entry: AuditEntry, key: string): void {
226
+ this.auditKeys.set(entry, key)
227
+ }
228
+
229
+ /**
230
+ * Runs `fn` on the cache and writes what it touched; on a version conflict, re-reads those rows and runs it
231
+ * again. Once loaded, the first run happens synchronously (like the file Store: a caller that does not await
232
+ * still sees its change in the cache at once); the writes follow.
233
+ */
234
+ mutate<T>(fn: (tx: StoreTx) => T): Promise<T> {
235
+ if (!this.loaded) return this.ready().then(() => this.attempt(fn))
236
+ try {
237
+ return this.attempt(fn)
238
+ } catch (error) {
239
+ return Promise.reject(error)
240
+ }
241
+ }
242
+
243
+ /** One run of `fn` (synchronous), then its writes and any retries. */
244
+ private attempt<T>(fn: (tx: StoreTx) => T, attempt = 1): Promise<T> {
245
+ const tx = new StoreTx()
246
+ const result = fn(tx)
247
+ if (!tx.dirty) return Promise.resolve(result)
248
+ const writes = [...tx.refs.values()].map((ref) => this.snapshot(ref))
249
+ return this.write(fn, writes, result, attempt)
250
+ }
251
+
252
+ private async write<T>(fn: (tx: StoreTx) => T, writes: readonly Write[], result: T, attempt: number): Promise<T> {
253
+ let ok: boolean
254
+ try {
255
+ ok = await this.commit(writes)
256
+ } catch (error) {
257
+ // The cache holds the change that was not written: put the rows back as they are.
258
+ await Promise.allSettled(writes.map((write) => this.refresh(write.ref)))
259
+ throw error
260
+ }
261
+ if (ok) return result
262
+ if (attempt >= MAX_ATTEMPTS) {
263
+ await Promise.allSettled(writes.map((write) => this.refresh(write.ref)))
264
+ throw new StoreConflictError()
265
+ }
266
+ await Promise.all(writes.map((write) => this.refresh(write.ref)))
267
+ return this.attempt(fn, attempt + 1)
268
+ }
269
+
270
+ private snapshot(ref: RowRef): Write {
271
+ const value = rowOf(this.cache.get(), ref, this)
272
+ return { ref, value: value === undefined ? undefined : structuredClone(value), expected: this.versions.get(refID(ref)) ?? 0 }
273
+ }
274
+
275
+ /** Writes the rows in order; false at the first version conflict. Publishes what was written. */
276
+ private async commit(writes: readonly Write[]): Promise<boolean> {
277
+ const changed: [string, string, number][] = []
278
+ let ok = true
279
+ try {
280
+ for (const { ref, value, expected } of writes) {
281
+ const id = refID(ref)
282
+ if (versioned(ref.collection)) {
283
+ if (value === undefined) {
284
+ if (expected === 0) continue
285
+ const result = await this.store.delete(HUB_ROWS, ref.collection, ref.key, { ifVersion: expected })
286
+ if (!result.ok) {
287
+ ok = false
288
+ break
289
+ }
290
+ this.versions.delete(id)
291
+ applyRow(this.cache.get(), ref, undefined, this)
292
+ changed.push([ref.collection, ref.key, 0])
293
+ } else {
294
+ const result = await this.store.put(HUB_ROWS, ref.collection, ref.key, value, { ifVersion: expected })
295
+ if (!result.ok) {
296
+ ok = false
297
+ break
298
+ }
299
+ this.versions.set(id, result.version)
300
+ // The cache holds what is now stored at that version (a refresh meanwhile may have replaced it).
301
+ applyRow(this.cache.get(), ref, structuredClone(value), this)
302
+ changed.push([ref.collection, ref.key, result.version])
303
+ }
304
+ } else if (value === undefined) {
305
+ await this.store.delete(HUB_ROWS, ref.collection, ref.key)
306
+ changed.push([ref.collection, ref.key, 0])
307
+ } else {
308
+ const result = await this.store.put(HUB_ROWS, ref.collection, ref.key, value, { ifVersion: 0 })
309
+ if (!result.ok) {
310
+ const current = await this.store.get(HUB_ROWS, ref.collection, ref.key)
311
+ // The same row already there (written by an earlier attempt of this mutation) is fine.
312
+ if (JSON.stringify(current?.value) === JSON.stringify(value)) continue
313
+ ok = false
314
+ break
315
+ }
316
+ changed.push([ref.collection, ref.key, result.version])
317
+ }
318
+ }
319
+ } finally {
320
+ if (changed.length) await this.publish(changed)
321
+ }
322
+ return ok
323
+ }
324
+
325
+ private async publish(rows: [string, string, number][]): Promise<void> {
326
+ const message: ChangedMessage = { from: this.instanceID, rows }
327
+ try {
328
+ await this.bus.publish(STORE_CHANGED_TOPIC, message)
329
+ } catch (error) {
330
+ this.warn(`[hub] store.changed not published: ${(error as Error)?.message ?? error}`)
331
+ }
332
+ }
333
+
334
+ private async onChanged(raw: unknown): Promise<void> {
335
+ if (this.closed || !this.loading) return
336
+ const message = raw as Partial<ChangedMessage> | null
337
+ if (!message || typeof message !== "object" || message.from === this.instanceID || !Array.isArray(message.rows)) return
338
+ const stale: RowRef[] = []
339
+ for (const row of message.rows) {
340
+ if (!Array.isArray(row) || typeof row[0] !== "string" || typeof row[1] !== "string" || typeof row[2] !== "number") continue
341
+ const ref = { collection: row[0] as Collection, key: row[1] }
342
+ if (!isCollection(ref.collection)) continue
343
+ if (versioned(ref.collection) && row[2] !== 0 && (this.versions.get(refID(ref)) ?? 0) >= row[2]) continue
344
+ stale.push(ref)
345
+ }
346
+ await Promise.all(stale.map((ref) => this.refresh(ref)))
347
+ }
348
+
349
+ /** Re-reads one row into the cache (an index row also re-reads the record it points to). */
350
+ async refresh(ref: RowRef): Promise<void> {
351
+ const row = await this.store.get(HUB_ROWS, ref.collection, ref.key)
352
+ const id = refID(ref)
353
+ if (versioned(ref.collection)) {
354
+ // Same version: the cache has it. Older: read before a newer write this pod already holds.
355
+ if (row && (this.versions.get(id) ?? 0) >= row.version) return
356
+ if (row) this.versions.set(id, row.version)
357
+ else this.versions.delete(id)
358
+ }
359
+ applyRow(this.cache.get(), ref, row?.value, this)
360
+ const target = row && indexTarget(ref.collection)
361
+ if (target) {
362
+ const pointed = (row.value as { id?: unknown }).id
363
+ if (typeof pointed === "string") await this.refresh({ collection: target, key: pointed })
364
+ }
365
+ }
366
+
367
+ /** The record id a token-hash index row points to (read from the database). */
368
+ async indexed(collection: "machine-tokens" | "device-tokens", hash: string): Promise<string | undefined> {
369
+ const row = await this.store.get<{ id?: unknown }>(HUB_ROWS, collection, hash)
370
+ return typeof row?.value?.id === "string" ? row.value.id : undefined
371
+ }
372
+ }
373
+
374
+ /**
375
+ * The row key of an audit entry at `at` that sorts after `last` (the greatest key so far):
376
+ * <time, 15 digits>-<sequence within that time, 6 digits>-<suffix: 8 hex>.
377
+ */
378
+ export function nextAuditKey(last: string | undefined, at: number, suffix: string): string {
379
+ const time0 = Math.max(0, Math.floor(at))
380
+ const [lastAt, lastSeq] = last ? [Number(last.slice(0, 15)), Number(last.slice(16, 22))] : [-1, -1]
381
+ const time = Math.max(time0, lastAt)
382
+ const seq = time === lastAt ? lastSeq + 1 : 0
383
+ return `${String(time).padStart(15, "0")}-${String(seq).padStart(6, "0")}-${suffix}`
384
+ }
385
+
386
+ export interface StateRow {
387
+ readonly collection: Collection
388
+ readonly key: string
389
+ readonly value: unknown
390
+ }
391
+
392
+ /**
393
+ * Every row a whole StateData is stored as (records, settings, audit with the given keys, then the index rows),
394
+ * through the same mapping the shared Store writes with (`hub-server import`).
395
+ */
396
+ export function stateRows(state: StateData, auditKey: (entry: AuditEntry) => string): StateRow[] {
397
+ const refs: RowRef[] = [
398
+ ...state.users.map((u) => ({ collection: "users" as const, key: u.id })),
399
+ ...state.machines.map((m) => ({ collection: "machines" as const, key: m.id })),
400
+ ...state.devices.map((d) => ({ collection: "devices" as const, key: d.id })),
401
+ ...state.push.map((p) => ({ collection: "push" as const, key: pushKey(p.endpoint) })),
402
+ ...SETTINGS.map((key) => ({ collection: "settings" as const, key })),
403
+ ...state.audit.map((entry) => ({ collection: "audit" as const, key: auditKey(entry) })),
404
+ ...state.machines.map((m) => ({ collection: "machine-tokens" as const, key: m.tokenHash })),
405
+ ...state.devices.map((d) => ({ collection: "device-tokens" as const, key: d.tokenHash })),
406
+ ...state.users.flatMap((u) => (u.email ? [{ collection: "user-emails" as const, key: u.email }] : [])),
407
+ ]
408
+ const rows: StateRow[] = []
409
+ for (const ref of refs) {
410
+ const value = rowOf(state, ref, { auditKey })
411
+ if (value !== undefined) rows.push({ ...ref, value })
412
+ }
413
+ return rows
414
+ }
415
+
416
+ const isCollection = (value: string): value is Collection =>
417
+ (["users", "machines", "devices", "push", "settings", "audit", "machine-tokens", "device-tokens", "user-emails"] as const).includes(value as Collection)
418
+
419
+ function indexTarget(collection: Collection): Collection | undefined {
420
+ if (collection === "machine-tokens") return "machines"
421
+ if (collection === "device-tokens") return "devices"
422
+ if (collection === "user-emails") return "users"
423
+ return undefined
424
+ }
425
+
426
+ function setSetting(state: StateData, key: string, value: unknown): void {
427
+ if (key === "vapid") {
428
+ if (value === undefined) delete state.vapid
429
+ else state.vapid = value as NonNullable<StateData["vapid"]>
430
+ } else if (key === "speech") {
431
+ if (value === undefined) delete state.speech
432
+ else state.speech = value as NonNullable<StateData["speech"]>
433
+ } else if (key === "agentsActingPaused") {
434
+ if (value === true) state.agentsActingPaused = true
435
+ else delete state.agentsActingPaused
436
+ }
437
+ }
438
+
439
+ /** The row value the cache holds for `ref` (undefined: the row should not exist). */
440
+ function rowOf(state: StateData, ref: RowRef, rows: Pick<SharedRows, "auditKey">): unknown {
441
+ const { collection, key } = ref
442
+ switch (collection) {
443
+ case "users":
444
+ return state.users.find((u) => u.id === key)
445
+ case "machines":
446
+ return state.machines.find((m) => m.id === key)
447
+ case "devices":
448
+ return state.devices.find((d) => d.id === key)
449
+ case "push":
450
+ return state.push.find((p) => pushKey(p.endpoint) === key)
451
+ case "settings":
452
+ return (SETTINGS as readonly string[]).includes(key) ? (state as unknown as Record<string, unknown>)[key] : undefined
453
+ case "audit":
454
+ return state.audit.find((entry) => rows.auditKey(entry) === key)
455
+ case "machine-tokens": {
456
+ const machine = state.machines.find((m) => m.tokenHash === key)
457
+ return machine && { id: machine.id }
458
+ }
459
+ case "device-tokens": {
460
+ const device = state.devices.find((d) => d.tokenHash === key)
461
+ return device && { id: device.id }
462
+ }
463
+ case "user-emails": {
464
+ const user = state.users.find((u) => u.email === key)
465
+ return user && { id: user.id }
466
+ }
467
+ }
468
+ }
469
+
470
+ /** Puts a row's value (undefined: removed) into the cache. Index rows are derived: nothing to do. */
471
+ function applyRow(state: StateData, ref: RowRef, value: unknown, rows: SharedRows): void {
472
+ const { collection, key } = ref
473
+ const replace = <T>(list: T[], match: (item: T) => boolean, sort?: (a: T, b: T) => number): T[] => {
474
+ const index = list.findIndex(match)
475
+ if (value === undefined) {
476
+ if (index >= 0) list.splice(index, 1)
477
+ } else if (index >= 0) list[index] = value as T
478
+ else {
479
+ list.push(value as T)
480
+ if (sort) list.sort(sort)
481
+ }
482
+ return list
483
+ }
484
+ switch (collection) {
485
+ case "users":
486
+ replace(state.users, (u) => u.id === key, byCreated)
487
+ return
488
+ case "machines":
489
+ replace(state.machines, (m) => m.id === key, byCreated)
490
+ return
491
+ case "devices":
492
+ replace(state.devices, (d) => d.id === key, byCreated)
493
+ return
494
+ case "push":
495
+ replace(state.push, (p) => pushKey(p.endpoint) === key, (a, b) => a.created - b.created)
496
+ return
497
+ case "settings":
498
+ setSetting(state, key, value)
499
+ return
500
+ case "audit": {
501
+ const index = state.audit.findIndex((entry) => rows.auditKey(entry) === key)
502
+ if (value === undefined) {
503
+ if (index >= 0) state.audit.splice(index, 1)
504
+ return
505
+ }
506
+ const entry = value as AuditEntry
507
+ if (index >= 0) state.audit.splice(index, 1)
508
+ // Keyed by time: insert in key order.
509
+ rows.rememberAudit(entry, key)
510
+ let at = state.audit.length
511
+ while (at > 0 && (rows.auditKey(state.audit[at - 1]!) ?? "") > key) at--
512
+ state.audit.splice(at, 0, entry)
513
+ return
514
+ }
515
+ default:
516
+ return
517
+ }
518
+ }