@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,38 @@
1
+ // Kv in process memory (a cluster uses Valkey SET [PX] [NX], GET, GETDEL, DEL). Expiry is evaluated lazily
2
+ // against the clock, like the counters: no timers; expired keys are dropped when touched and in periodic sweeps.
3
+ import { ExpiringMap } from "./memory-counters"
4
+ import type { Clock, Kv, KvSetOptions } from "./types"
5
+
6
+ export class MemoryKv implements Kv {
7
+ private readonly now: Clock
8
+ private readonly values: ExpiringMap<{ value: string; expiresAt: number }>
9
+
10
+ constructor(options: { now?: Clock } = {}) {
11
+ this.now = options.now ?? Date.now
12
+ this.values = new ExpiringMap(this.now)
13
+ }
14
+
15
+ async set(key: string, value: string, options: KvSetOptions = {}): Promise<boolean> {
16
+ const { px, nx } = options
17
+ if (px !== undefined && !(Number.isInteger(px) && px > 0)) throw new Error("Kv.set: px must be a positive whole number of milliseconds")
18
+ if (nx && this.values.get(key)) return false
19
+ this.values.set(key, { value, expiresAt: px === undefined ? Infinity : this.now() + px })
20
+ return true
21
+ }
22
+
23
+ async get(key: string): Promise<string | undefined> {
24
+ return this.values.get(key)?.value
25
+ }
26
+
27
+ async getdel(key: string): Promise<string | undefined> {
28
+ const entry = this.values.get(key)
29
+ if (entry) this.values.delete(key)
30
+ return entry?.value
31
+ }
32
+
33
+ async del(key: string): Promise<boolean> {
34
+ const existed = this.values.get(key) !== undefined
35
+ this.values.delete(key)
36
+ return existed
37
+ }
38
+ }
@@ -0,0 +1,77 @@
1
+ // StateStore in process memory (the single-process hub; a cluster uses Postgres). Values are copied in and out,
2
+ // like a database round trip, so callers never share mutable state with the store.
3
+ import type { Claim, ClaimResult, Clock, Data, Entry, StateStore, Versioned, WriteOptions, WriteResult } from "./types"
4
+
5
+ interface Stored {
6
+ value: Data
7
+ version: number
8
+ }
9
+
10
+ /** Map key for (a, b) pairs; NUL never occurs in ids or names. */
11
+ const pair = (a: string, b: string) => `${a}\u0000${b}`
12
+
13
+ export class MemoryStateStore implements StateStore {
14
+ private readonly now: Clock
15
+ /** pair(userID, collection) → key → entry. */
16
+ private readonly collections = new Map<string, Map<string, Stored>>()
17
+ /** pair(namespace, key) → claim. */
18
+ private readonly claims = new Map<string, Claim>()
19
+
20
+ constructor(options: { now?: Clock } = {}) {
21
+ this.now = options.now ?? Date.now
22
+ }
23
+
24
+ async get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined> {
25
+ const stored = this.collections.get(pair(userID, collection))?.get(key)
26
+ return stored && { value: structuredClone(stored.value) as T, version: stored.version }
27
+ }
28
+
29
+ async put<T = Data>(userID: string, collection: string, key: string, value: T, options: WriteOptions = {}): Promise<WriteResult> {
30
+ const id = pair(userID, collection)
31
+ let entries = this.collections.get(id)
32
+ const current = entries?.get(key)?.version ?? 0
33
+ if (options.ifVersion !== undefined && options.ifVersion !== current) return { ok: false, version: current }
34
+ if (!entries) this.collections.set(id, (entries = new Map()))
35
+ const version = current + 1
36
+ entries.set(key, { value: structuredClone(value), version })
37
+ return { ok: true, version }
38
+ }
39
+
40
+ async delete(userID: string, collection: string, key: string, options: WriteOptions = {}): Promise<WriteResult> {
41
+ const id = pair(userID, collection)
42
+ const entries = this.collections.get(id)
43
+ const current = entries?.get(key)?.version ?? 0
44
+ if (options.ifVersion !== undefined && options.ifVersion !== current) return { ok: false, version: current }
45
+ entries?.delete(key)
46
+ if (entries?.size === 0) this.collections.delete(id)
47
+ return { ok: true, version: 0 }
48
+ }
49
+
50
+ async list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]> {
51
+ const entries = this.collections.get(pair(userID, collection))
52
+ if (!entries) return []
53
+ return [...entries.entries()]
54
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
55
+ .map(([key, stored]) => ({ key, value: structuredClone(stored.value) as T, version: stored.version }))
56
+ }
57
+
58
+ async claim(namespace: string, key: string, owner: string): Promise<ClaimResult> {
59
+ const id = pair(namespace, key)
60
+ const held = this.claims.get(id)
61
+ if (held) return held.owner === owner ? { ok: true, claim: held } : { ok: false, conflict: held }
62
+ const claim: Claim = { namespace, key, owner, at: this.now() }
63
+ this.claims.set(id, claim)
64
+ return { ok: true, claim }
65
+ }
66
+
67
+ async release(namespace: string, key: string, owner: string): Promise<boolean> {
68
+ const id = pair(namespace, key)
69
+ if (this.claims.get(id)?.owner !== owner) return false
70
+ this.claims.delete(id)
71
+ return true
72
+ }
73
+
74
+ async claimOf(namespace: string, key: string): Promise<Claim | undefined> {
75
+ return this.claims.get(pair(namespace, key))
76
+ }
77
+ }
@@ -0,0 +1,100 @@
1
+ // Postgres schema for cluster mode (decision vkxkqiuf §3, design §10). Migrations are ordered, recorded in
2
+ // hub_schema and applied by `hub-server migrate` (a Job) under a session advisory lock, so concurrent runs apply
3
+ // each one exactly once; each runs in its own transaction. A database newer than the code is left alone: every
4
+ // schema must keep working with the previous release. Nothing here names the database URL.
5
+ import type { SQL } from "bun"
6
+
7
+ export interface Migration {
8
+ /** 1, 2, 3… in order. */
9
+ readonly version: number
10
+ readonly name: string
11
+ /** SQL statements, run in one transaction. */
12
+ readonly up: string
13
+ }
14
+
15
+ export const MIGRATIONS: readonly Migration[] = [
16
+ {
17
+ version: 1,
18
+ name: "state-and-claims",
19
+ up: `
20
+ CREATE TABLE hub_state (
21
+ user_id text COLLATE "C" NOT NULL,
22
+ collection text COLLATE "C" NOT NULL,
23
+ key text COLLATE "C" NOT NULL,
24
+ value jsonb NOT NULL,
25
+ version integer NOT NULL CHECK (version > 0),
26
+ updated_at timestamptz NOT NULL DEFAULT now(),
27
+ PRIMARY KEY (user_id, collection, key)
28
+ );
29
+ CREATE TABLE hub_claims (
30
+ namespace text COLLATE "C" NOT NULL,
31
+ key text COLLATE "C" NOT NULL,
32
+ owner text NOT NULL,
33
+ at bigint NOT NULL,
34
+ PRIMARY KEY (namespace, key)
35
+ );
36
+ `,
37
+ },
38
+ ]
39
+
40
+ /** The schema version this code needs. */
41
+ export const SCHEMA_VERSION = MIGRATIONS.at(-1)?.version ?? 0
42
+
43
+ /** pg_advisory_lock key of the migrations ("hubsch" in ASCII). */
44
+ export const MIGRATION_LOCK_KEY = 0x6875_6273_6368
45
+
46
+ const SCHEMA_TABLE = `
47
+ CREATE TABLE IF NOT EXISTS hub_schema (
48
+ version integer PRIMARY KEY,
49
+ name text NOT NULL,
50
+ applied_at timestamptz NOT NULL DEFAULT now()
51
+ )
52
+ `
53
+
54
+ /** The database's schema version: the latest applied migration (0 before the first). */
55
+ export async function schemaVersion(sql: SQL): Promise<number> {
56
+ const [exists] = (await sql`SELECT to_regclass('public.hub_schema') IS NOT NULL AS ok`) as { ok: boolean }[]
57
+ if (!exists?.ok) return 0
58
+ const [row] = (await sql`SELECT coalesce(max(version), 0)::int AS version FROM hub_schema`) as { version: number }[]
59
+ return row?.version ?? 0
60
+ }
61
+
62
+ export interface MigrateResult {
63
+ /** The versions this run applied (empty when another run or an earlier one did). */
64
+ readonly applied: number[]
65
+ /** The schema version afterwards. */
66
+ readonly version: number
67
+ }
68
+
69
+ /** Applies the pending migrations under the advisory lock; idempotent and safe to run concurrently. */
70
+ export async function migrate(sql: SQL, options: { readonly migrations?: readonly Migration[] } = {}): Promise<MigrateResult> {
71
+ const migrations = options.migrations ?? MIGRATIONS
72
+ const conn = await sql.reserve()
73
+ try {
74
+ await conn`SELECT pg_advisory_lock(${String(MIGRATION_LOCK_KEY)}::bigint)`
75
+ try {
76
+ await conn.unsafe(SCHEMA_TABLE)
77
+ const done = new Set(((await conn`SELECT version FROM hub_schema`) as { version: number }[]).map((r) => r.version))
78
+ const applied: number[] = []
79
+ for (const migration of migrations) {
80
+ if (done.has(migration.version)) continue
81
+ // BEGIN/COMMIT by hand: Bun 1.3.5's reserved-connection begin() also leaves the failure unhandled.
82
+ await conn.unsafe("BEGIN")
83
+ try {
84
+ await conn.unsafe(migration.up)
85
+ await conn`INSERT INTO hub_schema (version, name) VALUES (${migration.version}, ${migration.name})`
86
+ await conn.unsafe("COMMIT")
87
+ } catch (error) {
88
+ await conn.unsafe("ROLLBACK").catch(() => {})
89
+ throw new Error(`migration ${migration.version} (${migration.name}) failed: ${(error as Error).message}`, { cause: error })
90
+ }
91
+ applied.push(migration.version)
92
+ }
93
+ return { applied, version: await schemaVersion(conn) }
94
+ } finally {
95
+ await conn`SELECT pg_advisory_unlock(${String(MIGRATION_LOCK_KEY)}::bigint)`
96
+ }
97
+ } finally {
98
+ conn.release()
99
+ }
100
+ }
@@ -0,0 +1,197 @@
1
+ // StateStore in Postgres (cluster mode; decision vkxkqiuf §3). Entries live in one jsonb table, hub_state
2
+ // (user_id, collection, key, value, version); unique claims in hub_claims with the primary key as the unique
3
+ // constraint. Versions change in single statements, so concurrent pods never lose a write:
4
+ // - put without ifVersion: an upsert that bumps the version
5
+ // - put with ifVersion 0: INSERT … ON CONFLICT DO NOTHING
6
+ // - put with ifVersion n: UPDATE … WHERE version = n (a concurrent writer re-checks the row it waited for)
7
+ // and a refused write reports the version read right after. The client connects on first use (the hub builds its
8
+ // backends synchronously) and first checks the schema (see ./postgres-migrate), or applies it with `migrate`.
9
+ // The URL holds credentials: it stays in a private field and is never put in a message.
10
+ import { SQL } from "bun"
11
+ import { migrate, SCHEMA_VERSION, schemaVersion } from "./postgres-migrate"
12
+ import type { Claim, ClaimResult, Clock, Data, Entry, StateStore, Versioned, WriteOptions, WriteResult } from "./types"
13
+
14
+ export interface PostgresStateStoreOptions {
15
+ /** postgres:// or postgresql:// (with credentials). */
16
+ readonly url: string
17
+ readonly now?: Clock
18
+ /** Apply pending migrations on first use (default false: they must have run, `hub-server migrate`). */
19
+ readonly migrate?: boolean
20
+ /** Pool size (default 10). */
21
+ readonly max?: number
22
+ /** Seconds to wait for a connection (default Bun's). */
23
+ readonly connectionTimeoutSeconds?: number
24
+ }
25
+
26
+ /** The database is behind the schema this hub needs. The message names no URL. */
27
+ export class SchemaOutdatedError extends Error {
28
+ constructor(readonly found: number, readonly needed: number) {
29
+ super(`The Postgres schema is at version ${found}, this hub needs ${needed}: run \`hub-server migrate\``)
30
+ this.name = "SchemaOutdatedError"
31
+ }
32
+ }
33
+
34
+ /** Attempts of a conditional write whose row changed between the write and the version read. */
35
+ const RACE_RETRIES = 8
36
+
37
+ interface Row {
38
+ value: string
39
+ version: number
40
+ }
41
+
42
+ interface ClaimRow {
43
+ owner: string
44
+ at: number
45
+ }
46
+
47
+ export class PostgresStateStore implements StateStore {
48
+ readonly #url: string
49
+ readonly #options: Omit<PostgresStateStoreOptions, "url">
50
+ readonly #now: Clock
51
+ #sql?: SQL
52
+ #ready?: Promise<void>
53
+
54
+ constructor(options: PostgresStateStoreOptions) {
55
+ const { url, ...rest } = options
56
+ this.#url = url
57
+ this.#options = rest
58
+ this.#now = options.now ?? Date.now
59
+ }
60
+
61
+ async get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined> {
62
+ const sql = await this.#client()
63
+ const [row] = (await sql`
64
+ SELECT value::text AS value, version FROM hub_state
65
+ WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key}`) as Row[]
66
+ return row && { value: JSON.parse(row.value) as T, version: row.version }
67
+ }
68
+
69
+ async put<T = Data>(userID: string, collection: string, key: string, value: T, options: WriteOptions = {}): Promise<WriteResult> {
70
+ const sql = await this.#client()
71
+ const json = JSON.stringify(value ?? null)
72
+ const expected = options.ifVersion
73
+ if (expected === undefined) {
74
+ const [row] = (await sql`
75
+ INSERT INTO hub_state (user_id, collection, key, value, version)
76
+ VALUES (${userID}, ${collection}, ${key}, (${json}::text)::jsonb, 1)
77
+ ON CONFLICT (user_id, collection, key)
78
+ DO UPDATE SET value = EXCLUDED.value, version = hub_state.version + 1, updated_at = now()
79
+ RETURNING version`) as { version: number }[]
80
+ return { ok: true, version: row!.version }
81
+ }
82
+ for (let attempt = 0; ; attempt++) {
83
+ const rows = (expected === 0
84
+ ? await sql`
85
+ INSERT INTO hub_state (user_id, collection, key, value, version)
86
+ VALUES (${userID}, ${collection}, ${key}, (${json}::text)::jsonb, 1)
87
+ ON CONFLICT (user_id, collection, key) DO NOTHING
88
+ RETURNING version`
89
+ : await sql`
90
+ UPDATE hub_state SET value = (${json}::text)::jsonb, version = version + 1, updated_at = now()
91
+ WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key} AND version = ${expected}
92
+ RETURNING version`) as { version: number }[]
93
+ if (rows[0]) return { ok: true, version: rows[0].version }
94
+ const current = await this.#version(sql, userID, collection, key)
95
+ // The row changed back to the expected version in between (deleted and recreated): try again.
96
+ if (current !== expected || attempt >= RACE_RETRIES) return { ok: false, version: current }
97
+ }
98
+ }
99
+
100
+ async delete(userID: string, collection: string, key: string, options: WriteOptions = {}): Promise<WriteResult> {
101
+ const sql = await this.#client()
102
+ const expected = options.ifVersion
103
+ if (expected === undefined) {
104
+ await sql`DELETE FROM hub_state WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key}`
105
+ return { ok: true, version: 0 }
106
+ }
107
+ for (let attempt = 0; ; attempt++) {
108
+ if (expected !== 0) {
109
+ const rows = await sql`
110
+ DELETE FROM hub_state
111
+ WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key} AND version = ${expected}
112
+ RETURNING version`
113
+ if (rows.length) return { ok: true, version: 0 }
114
+ }
115
+ const current = await this.#version(sql, userID, collection, key)
116
+ if (expected === 0 && current === 0) return { ok: true, version: 0 }
117
+ if (current !== expected || attempt >= RACE_RETRIES) return { ok: false, version: current }
118
+ }
119
+ }
120
+
121
+ async list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]> {
122
+ const sql = await this.#client()
123
+ const rows = (await sql`
124
+ SELECT key, value::text AS value, version FROM hub_state
125
+ WHERE user_id = ${userID} AND collection = ${collection}`) as (Row & { key: string })[]
126
+ // Sorted here, by UTF-16 code units like the other stores (not by a database collation).
127
+ return rows
128
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
129
+ .map((row) => ({ key: row.key, value: JSON.parse(row.value) as T, version: row.version }))
130
+ }
131
+
132
+ async claim(namespace: string, key: string, owner: string): Promise<ClaimResult> {
133
+ const sql = await this.#client()
134
+ for (let attempt = 0; ; attempt++) {
135
+ const [inserted] = (await sql`
136
+ INSERT INTO hub_claims (namespace, key, owner, at) VALUES (${namespace}, ${key}, ${owner}, ${this.#now()})
137
+ ON CONFLICT (namespace, key) DO NOTHING
138
+ RETURNING owner, at::float8 AS at`) as ClaimRow[]
139
+ if (inserted) return { ok: true, claim: { namespace, key, owner: inserted.owner, at: inserted.at } }
140
+ const held = await this.claimOf(namespace, key)
141
+ if (held) return held.owner === owner ? { ok: true, claim: held } : { ok: false, conflict: held }
142
+ // Released in between: claim again.
143
+ if (attempt >= RACE_RETRIES) throw new Error(`claim ${namespace}/${key} kept changing; try again`)
144
+ }
145
+ }
146
+
147
+ async release(namespace: string, key: string, owner: string): Promise<boolean> {
148
+ const sql = await this.#client()
149
+ const rows = await sql`DELETE FROM hub_claims WHERE namespace = ${namespace} AND key = ${key} AND owner = ${owner} RETURNING owner`
150
+ return rows.length > 0
151
+ }
152
+
153
+ async claimOf(namespace: string, key: string): Promise<Claim | undefined> {
154
+ const sql = await this.#client()
155
+ const [row] = (await sql`SELECT owner, at::float8 AS at FROM hub_claims WHERE namespace = ${namespace} AND key = ${key}`) as ClaimRow[]
156
+ return row && { namespace, key, owner: row.owner, at: row.at }
157
+ }
158
+
159
+ /** Closes the connection pool (a later call opens a new one). */
160
+ /** Readiness: SELECT 1 (after the schema check; rejects when the database does not answer, never naming the url). */
161
+ async ping(): Promise<void> {
162
+ const sql = await this.#client()
163
+ await sql`SELECT 1`
164
+ }
165
+
166
+ async close(): Promise<void> {
167
+ const sql = this.#sql
168
+ this.#sql = undefined
169
+ this.#ready = undefined
170
+ await sql?.close()
171
+ }
172
+
173
+ async #version(sql: SQL, userID: string, collection: string, key: string): Promise<number> {
174
+ const [row] = (await sql`
175
+ SELECT version FROM hub_state WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key}`) as { version: number }[]
176
+ return row?.version ?? 0
177
+ }
178
+
179
+ /** The pool, created on first use, once the schema is current; a failed check is retried by the next call. */
180
+ async #client(): Promise<SQL> {
181
+ const sql = (this.#sql ??= new SQL(this.#url, {
182
+ max: this.#options.max ?? 10,
183
+ ...(this.#options.connectionTimeoutSeconds !== undefined ? { connectionTimeout: this.#options.connectionTimeoutSeconds } : {}),
184
+ }))
185
+ this.#ready ??= this.#checkSchema(sql).catch((error: unknown) => {
186
+ this.#ready = undefined
187
+ throw error
188
+ })
189
+ await this.#ready
190
+ return sql
191
+ }
192
+
193
+ async #checkSchema(sql: SQL): Promise<void> {
194
+ const version = this.#options.migrate ? (await migrate(sql)).version : await schemaVersion(sql)
195
+ if (version < SCHEMA_VERSION) throw new SchemaOutdatedError(version, SCHEMA_VERSION)
196
+ }
197
+ }
@@ -0,0 +1,244 @@
1
+ // Backend interfaces for state the hub shares between processes (design §9). The single-process hub uses the
2
+ // in-memory implementations (./memory-*); a cluster swaps in Postgres / Valkey ones behind the same contracts.
3
+ // Everything is async so the networked implementations fit. Only new code uses these: existing state (Store,
4
+ // the live-stream EventLog, the Registry) stays where it is.
5
+
6
+ /** Milliseconds since the epoch (injectable for tests). */
7
+ export type Clock = () => number
8
+
9
+ /** Timer functions (injectable for tests); the same shape as the Registry's. */
10
+ export interface Timers {
11
+ set(fn: () => void, ms: number): unknown
12
+ clear(handle: unknown): void
13
+ }
14
+
15
+ /** Values the backends store or carry: JSON-compatible data (implementations may serialize them). */
16
+ export type Data = unknown
17
+
18
+ // ---- StateStore -------------------------------------------------------------------------------------------
19
+
20
+ /** A stored value and its version (1 for the first write, +1 for every later one). */
21
+ export interface Versioned<T = Data> {
22
+ readonly value: T
23
+ readonly version: number
24
+ }
25
+
26
+ export interface Entry<T = Data> extends Versioned<T> {
27
+ readonly key: string
28
+ }
29
+
30
+ /** Optimistic concurrency: `ifVersion` is the version the caller read (0: the key must not exist yet). */
31
+ export interface WriteOptions {
32
+ readonly ifVersion?: number
33
+ }
34
+
35
+ /** A write's outcome; on a version mismatch, the version the key has now (0: it does not exist). */
36
+ export type WriteResult = { readonly ok: true; readonly version: number } | { readonly ok: false; readonly version: number }
37
+
38
+ /** Who holds a unique name (an @name, an exclusive role) in a namespace. */
39
+ export interface Claim {
40
+ readonly namespace: string
41
+ readonly key: string
42
+ readonly owner: string
43
+ readonly at: number
44
+ }
45
+
46
+ /** A claim's outcome: the claim, or (conflict) the claim someone else already holds. */
47
+ export type ClaimResult = { readonly ok: true; readonly claim: Claim } | { readonly ok: false; readonly conflict: Claim }
48
+
49
+ /**
50
+ * Durable agent data, per user and collection, with optimistic versions; plus unique claims (a database unique
51
+ * constraint in a cluster).
52
+ */
53
+ export interface StateStore {
54
+ get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined>
55
+ put<T = Data>(userID: string, collection: string, key: string, value: T, options?: WriteOptions): Promise<WriteResult>
56
+ /** Removes a key; `ok: false` on a version mismatch. Removing a missing key (without ifVersion) is ok. */
57
+ delete(userID: string, collection: string, key: string, options?: WriteOptions): Promise<WriteResult>
58
+ /** The user's entries in a collection, by key. */
59
+ list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]>
60
+ /** Claims `key` in `namespace` for `owner`; claiming again for the same owner is ok (the original claim). */
61
+ claim(namespace: string, key: string, owner: string): Promise<ClaimResult>
62
+ /** Gives a claim up; false when `owner` does not hold it. */
63
+ release(namespace: string, key: string, owner: string): Promise<boolean>
64
+ claimOf(namespace: string, key: string): Promise<Claim | undefined>
65
+ /** Makes buffered writes durable (the hub calls it on stop); stores that write through omit it. */
66
+ flush?(): Promise<void>
67
+ /** Readiness (/readyz): rejects when the database does not answer. In-process stores omit it. */
68
+ ping?(): Promise<void>
69
+ }
70
+
71
+ // ---- Bus --------------------------------------------------------------------------------------------------
72
+
73
+ export type Unsubscribe = () => Promise<void>
74
+ export type BusHandler = (message: Data, topic: string) => void | Promise<void>
75
+ export type BusResponder = (message: Data, topic: string) => Data | Promise<Data>
76
+
77
+ export interface RequestOptions {
78
+ readonly timeoutMs: number
79
+ }
80
+
81
+ /**
82
+ * Cross-process messages: fire-and-forget publish/subscribe, and request/reply (RPC to the process holding a
83
+ * connection). Delivery is at most once; a subscriber's failure never reaches the publisher.
84
+ */
85
+ export interface Bus {
86
+ /** Sends a message to the topic's current subscribers; resolves with how many there were. */
87
+ publish(topic: string, message: Data): Promise<number>
88
+ subscribe(topic: string, handler: BusHandler): Promise<Unsubscribe>
89
+ /** Answers requests on a topic (one responder per topic). */
90
+ serve(topic: string, responder: BusResponder): Promise<Unsubscribe>
91
+ /**
92
+ * Asks the topic's responder; rejects with BusNoResponderError when there is none, BusTimeoutError after
93
+ * `timeoutMs`, or BusRemoteError when the responder throws.
94
+ */
95
+ request<T = Data>(topic: string, message: Data, options: RequestOptions): Promise<T>
96
+ }
97
+
98
+ export class BusTimeoutError extends Error {
99
+ constructor(readonly topic: string, readonly timeoutMs: number) {
100
+ super(`no reply on ${topic} within ${timeoutMs} ms`)
101
+ this.name = "BusTimeoutError"
102
+ }
103
+ }
104
+
105
+ export class BusNoResponderError extends Error {
106
+ constructor(readonly topic: string) {
107
+ super(`no responder on ${topic}`)
108
+ this.name = "BusNoResponderError"
109
+ }
110
+ }
111
+
112
+ export class BusRemoteError extends Error {
113
+ constructor(readonly topic: string, message: string) {
114
+ super(message)
115
+ this.name = "BusRemoteError"
116
+ }
117
+ }
118
+
119
+ // ---- EventLog ---------------------------------------------------------------------------------------------
120
+
121
+ export type EventMessage = { type: string; [key: string]: unknown }
122
+
123
+ export interface LogEvent {
124
+ readonly id: string
125
+ readonly message: EventMessage
126
+ /** Not for this device (the one that made the change). */
127
+ readonly exceptDevice?: string
128
+ readonly at: number
129
+ }
130
+
131
+ /** The events after an id, or `resync` when the log cannot cover the gap (the client reloads). */
132
+ export type SinceResult = { readonly events: LogEvent[] } | { readonly resync: true }
133
+
134
+ /** Per-user replay of the live stream, bounded by count and age (a Redis Stream in a cluster). */
135
+ export interface EventLog {
136
+ append(userID: string, message: EventMessage, options?: { readonly exceptDevice?: string }): Promise<string>
137
+ /** The id of the user's latest event; a client holding it is covered until events are dropped. */
138
+ latest(userID: string): Promise<string>
139
+ since(userID: string, lastID: string): Promise<SinceResult>
140
+ }
141
+
142
+ // ---- Counters / Locks -------------------------------------------------------------------------------------
143
+
144
+ /** Fixed-window counters (rate limits, budgets): INCR + PEXPIRE NX in a cluster. */
145
+ export interface Counters {
146
+ /** Adds one and returns the count; the first increment of a window starts its `ttlMs`. */
147
+ incr(key: string, ttlMs: number): Promise<number>
148
+ /** The current count (0 when unset or expired). */
149
+ get(key: string): Promise<number>
150
+ }
151
+
152
+ /** A held lock. `fence` grows with every new acquisition of a key (fencing token). */
153
+ export interface Lease {
154
+ readonly key: string
155
+ readonly owner: string
156
+ readonly fence: number
157
+ readonly expiresAt: number
158
+ }
159
+
160
+ /** Expiring exclusive leases (SET NX PX in a cluster). */
161
+ export interface Locks {
162
+ /** Takes the lock, or undefined while someone else holds it; the holder acquiring again renews. */
163
+ acquire(key: string, owner: string, ttlMs: number): Promise<Lease | undefined>
164
+ /** Extends the owner's lease; undefined when it no longer holds the lock (expired or taken). */
165
+ renew(key: string, owner: string, ttlMs: number): Promise<Lease | undefined>
166
+ /** Releases the owner's lease; false when it does not hold it. */
167
+ release(key: string, owner: string): Promise<boolean>
168
+ holder(key: string): Promise<Lease | undefined>
169
+ }
170
+
171
+ // ---- Kv ---------------------------------------------------------------------------------------------------
172
+
173
+ /** `set` options: `px` expires the key after that many milliseconds; `nx` writes only when the key is absent. */
174
+ export interface KvSetOptions {
175
+ readonly px?: number
176
+ readonly nx?: boolean
177
+ }
178
+
179
+ /**
180
+ * Short-lived shared strings (pairing and sign-in codes, OIDC state, directory entries): SET [PX] [NX], GET,
181
+ * GETDEL and DEL in a cluster. Values are strings; callers encode structured data themselves.
182
+ */
183
+ export interface Kv {
184
+ /** Writes the value (dropping any earlier expiry unless `px` is given); false when `nx` and the key exists. */
185
+ set(key: string, value: string, options?: KvSetOptions): Promise<boolean>
186
+ get(key: string): Promise<string | undefined>
187
+ /** Reads and removes the key in one step (a one-time code is redeemed once). */
188
+ getdel(key: string): Promise<string | undefined>
189
+ /** Removes the key; true when it existed. */
190
+ del(key: string): Promise<boolean>
191
+ /** Readiness (/readyz): rejects when the server does not answer. In-process stores omit it. */
192
+ ping?(): Promise<void>
193
+ }
194
+
195
+ // ---- Catalog ----------------------------------------------------------------------------------------------
196
+
197
+ export type CatalogValue = Record<string, unknown>
198
+
199
+ /** What one connection reports about a session. */
200
+ export interface CatalogEntry<T extends CatalogValue = CatalogValue> {
201
+ readonly sessionID: string
202
+ readonly connID: string
203
+ readonly value: T
204
+ readonly at: number
205
+ }
206
+
207
+ /** A session as all its connections report it, merged. */
208
+ export interface CatalogSession<T extends CatalogValue = CatalogValue> {
209
+ readonly sessionID: string
210
+ readonly value: T
211
+ /** The connections reporting it, oldest report first. */
212
+ readonly connIDs: string[]
213
+ /** When the latest report came. */
214
+ readonly at: number
215
+ }
216
+
217
+ /** Shared session state per user, keyed by (sessionID, connID) and merged on read (a Redis hash per user). */
218
+ export interface Catalog {
219
+ /** Records a connection's report; `ttlMs` lets reports of a dead process expire. */
220
+ set(userID: string, sessionID: string, connID: string, value: CatalogValue, options?: { readonly ttlMs?: number }): Promise<void>
221
+ /** Removes one connection's report of a session, or (no connID) every report of it. */
222
+ delete(userID: string, sessionID: string, connID?: string): Promise<void>
223
+ /** Removes everything a connection reported (it went away). */
224
+ deleteConn(userID: string, connID: string): Promise<void>
225
+ get(userID: string, sessionID: string): Promise<CatalogSession | undefined>
226
+ /** The user's sessions, merged, by session id. */
227
+ list(userID: string): Promise<CatalogSession[]>
228
+ /** The raw reports of the user (unmerged). */
229
+ entries(userID: string): Promise<CatalogEntry[]>
230
+ }
231
+
232
+ // ---- Bundle -----------------------------------------------------------------------------------------------
233
+
234
+ export interface Backends {
235
+ readonly state: StateStore
236
+ readonly bus: Bus
237
+ readonly events: EventLog
238
+ readonly counters: Counters
239
+ readonly locks: Locks
240
+ readonly catalog: Catalog
241
+ readonly kv: Kv
242
+ /** Closes the connections a networked bundle opened (cluster mode); the hub calls it on stop. */
243
+ close?(): Promise<void>
244
+ }