@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,318 @@
1
+ // `hub-server import --state <dir>` (design §10, decision vkxkqiuf): moves a single-process hub's state into the
2
+ // shared StateStore a cluster reads (Postgres), idempotently.
3
+ //
4
+ // - state.json is read through the Store's own load/upgrade code (Store.read: older formats are upgraded, nothing is
5
+ // written back) and stored as the rows Store.shared reads (stateRows in ./store-shared: records, settings, audit,
6
+ // token-hash and email index rows). Token hashes are copied, so machines and devices need no re-enrolment.
7
+ // - agent/** (the FileStateStore: per-user collections and claim namespaces) becomes the same rows and claims.
8
+ // - token-keys.json is never stored: the report names its key ids, and tokenKeysMaterial() returns it as the
9
+ // HUB_TOKEN_KEYS Secret value for the operator (`--print-token-keys`).
10
+ // Every row is inserted with ifVersion 0. A row already there is left alone: unchanged when equal (JSON with keys in
11
+ // any order: Postgres jsonb reorders them), a conflict otherwise, overwritten only with `force`. A dry run reads the
12
+ // target and counts, but writes nothing. The source directory is only read; stop the single-process hub first, so
13
+ // its files are complete.
14
+ import { createHash } from "node:crypto"
15
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
16
+ import path from "node:path"
17
+ import { AGENT_STATE_DIR, FileStateStore } from "./backend/file-state"
18
+ import type { StateStore } from "./backend/types"
19
+ import { HUB_ROWS, Store, type AuditEntry } from "./store"
20
+ import { nextAuditKey, stateRows } from "./store-shared"
21
+ import { TOKEN_KEYS_ENV, TOKEN_KEYS_FILE, TokenKeys } from "./token-keys"
22
+
23
+ export interface ImportOptions {
24
+ /** The single-process hub's state directory (state.json, agent/, token-keys.json). */
25
+ readonly stateDir: string
26
+ /** Where the rows go (Postgres in a cluster). */
27
+ readonly state: StateStore
28
+ /** Count only; write nothing. */
29
+ readonly dryRun?: boolean
30
+ /** Overwrite rows (and claims) that exist with different contents. */
31
+ readonly force?: boolean
32
+ /**
33
+ * The clock of the state.json upgrade (default: state.json's modification time, so records the upgrade adds, like
34
+ * the local owner, come out the same on every run).
35
+ */
36
+ readonly now?: () => number
37
+ }
38
+
39
+ /** Per collection: rows read from the source and what happened to them (in a dry run: would happen). */
40
+ export interface CollectionCounts {
41
+ rows: number
42
+ /** Not there yet: written. */
43
+ created: number
44
+ /** Already there with the same contents. */
45
+ unchanged: number
46
+ /** Already there with other contents (skipped unless forced). */
47
+ conflicts: number
48
+ /** Conflicts written over (force). */
49
+ overwritten: number
50
+ failed: number
51
+ }
52
+
53
+ export interface ImportReport {
54
+ readonly stateDir: string
55
+ readonly dryRun: boolean
56
+ readonly force: boolean
57
+ /** Whether state.json was there. */
58
+ readonly stateFile: boolean
59
+ /** Agent StateStore files read (collections and claim namespaces). */
60
+ readonly agentFiles: number
61
+ /** By collection: the Store's ("users", …), agent ones as "agent:<collection>", claims as "claims:<namespace>". */
62
+ readonly collections: Record<string, CollectionCounts>
63
+ /** The rows that differ: "<collection> <user>/<key>". */
64
+ readonly conflicts: string[]
65
+ /** Rows that could not be written, with the reason (never a value). */
66
+ readonly failures: { readonly row: string; readonly error: string }[]
67
+ readonly failed: number
68
+ /** The signing keys found (ids only), or undefined without token-keys.json. */
69
+ readonly tokenKeys?: { readonly active: string; readonly kids: string[] }
70
+ }
71
+
72
+ /** One row (or claim) to import. */
73
+ interface Item {
74
+ readonly collection: string
75
+ readonly label: string
76
+ /** What the target holds: absent, equal or different (with what to compare-and-set against). */
77
+ inspect(): Promise<{ readonly state: "absent" } | { readonly state: "same" } | { readonly state: "differs"; readonly version: number; readonly owner?: string }>
78
+ /** Inserts it; false when someone else wrote it meanwhile. */
79
+ create(): Promise<boolean>
80
+ /** Writes it over the differing row seen by inspect; false when it changed meanwhile. */
81
+ overwrite(seen: { readonly version: number; readonly owner?: string }): Promise<boolean>
82
+ }
83
+
84
+ /** Imports the state directory into `options.state`; see the file comment. */
85
+ export async function importState(options: ImportOptions): Promise<ImportReport> {
86
+ const { stateDir, state: target } = options
87
+ const dryRun = options.dryRun === true
88
+ const force = options.force === true
89
+ const agentRoot = path.join(stateDir, AGENT_STATE_DIR)
90
+ const stateFile = existsSync(path.join(stateDir, "state.json"))
91
+ if (!stateFile && !existsSync(agentRoot)) throw new Error(`No hub state in ${stateDir} (neither state.json nor ${AGENT_STATE_DIR}/)`)
92
+
93
+ const items: Item[] = []
94
+ // Store rows: state.json upgraded by the Store's own code, mapped by the shared Store's own row layout.
95
+ const stateMtime = stateFile ? Math.floor(statSync(path.join(stateDir, "state.json")).mtimeMs) : 0
96
+ const data = Store.read(stateDir, options.now ?? (() => stateMtime))
97
+ if (data) {
98
+ const keys = auditKeys(data.audit)
99
+ for (const row of stateRows(data, (entry) => keys.get(entry)!)) items.push(rowItem(target, row.collection, HUB_ROWS, row.collection, row.key, row.value))
100
+ }
101
+ // Agent rows and claims, read through the FileStateStore (it checks each file belongs to its ids).
102
+ const source = new FileStateStore({ stateDir })
103
+ const files = agentFiles(agentRoot)
104
+ for (const { userID, collection } of files.collections) {
105
+ for (const entry of await source.list(userID, collection)) items.push(rowItem(target, `agent:${collection}`, userID, collection, entry.key, entry.value))
106
+ }
107
+ for (const { namespace, keys } of files.claims) {
108
+ for (const key of keys) {
109
+ const claim = await source.claimOf(namespace, key)
110
+ if (claim) items.push(claimItem(target, namespace, key, claim.owner))
111
+ }
112
+ }
113
+
114
+ const collections: Record<string, CollectionCounts> = {}
115
+ const conflicts: string[] = []
116
+ const failures: { row: string; error: string }[] = []
117
+ for (const item of items) {
118
+ const counts = (collections[item.collection] ??= { rows: 0, created: 0, unchanged: 0, conflicts: 0, overwritten: 0, failed: 0 })
119
+ counts.rows++
120
+ try {
121
+ const seen = await item.inspect()
122
+ if (seen.state === "same") counts.unchanged++
123
+ else if (seen.state === "absent") {
124
+ if (dryRun || (await item.create())) counts.created++
125
+ else {
126
+ counts.failed++
127
+ failures.push({ row: item.label, error: "written by someone else during the import" })
128
+ }
129
+ } else {
130
+ counts.conflicts++
131
+ conflicts.push(item.label)
132
+ if (!force) continue
133
+ if (dryRun || (await item.overwrite(seen))) counts.overwritten++
134
+ else {
135
+ counts.failed++
136
+ failures.push({ row: item.label, error: "changed by someone else during the import" })
137
+ }
138
+ }
139
+ } catch (error) {
140
+ counts.failed++
141
+ failures.push({ row: item.label, error: (error as Error)?.message ?? String(error) })
142
+ }
143
+ }
144
+
145
+ const keys = readTokenKeys(stateDir)
146
+ return {
147
+ stateDir,
148
+ dryRun,
149
+ force,
150
+ stateFile,
151
+ agentFiles: files.collections.length + files.claims.length,
152
+ collections,
153
+ conflicts,
154
+ failures,
155
+ failed: failures.length,
156
+ ...(keys ? { tokenKeys: { active: keys.active, kids: keys.kids } } : {}),
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The signing keys of the state directory as the HUB_TOKEN_KEYS Secret value (one JSON line), validated; undefined
162
+ * without token-keys.json. Secret material: print it only when asked, never log or store it.
163
+ */
164
+ export function tokenKeysMaterial(stateDir: string): string | undefined {
165
+ return readTokenKeys(stateDir)?.material
166
+ }
167
+
168
+ function readTokenKeys(stateDir: string): { material: string; active: string; kids: string[] } | undefined {
169
+ const file = path.join(stateDir, TOKEN_KEYS_FILE)
170
+ if (!existsSync(file)) return undefined
171
+ const text = readFileSync(file, "utf8")
172
+ // Validated by TokenKeys itself (given as the override, so it never touches the file); errors name no secret.
173
+ const keys = TokenKeys.open(stateDir, { env: { [TOKEN_KEYS_ENV]: text } })
174
+ let kids: string[]
175
+ try {
176
+ kids = keys.kids()
177
+ } catch (error) {
178
+ throw new Error(((error as Error).message ?? "").replace(TOKEN_KEYS_ENV, file))
179
+ }
180
+ return { material: JSON.stringify(JSON.parse(text)), active: keys.toJSON().active, kids }
181
+ }
182
+
183
+ /**
184
+ * Stable row keys for the audit entries (in order): the shared Store's key layout, with a suffix derived from the
185
+ * entry and its position instead of a random one, so a second import finds the same rows.
186
+ */
187
+ function auditKeys(audit: readonly AuditEntry[]): Map<AuditEntry, string> {
188
+ const keys = new Map<AuditEntry, string>()
189
+ let last: string | undefined
190
+ audit.forEach((entry, index) => {
191
+ const suffix = createHash("sha256").update(`${index}\n${JSON.stringify(entry)}`).digest("hex").slice(0, 8)
192
+ last = nextAuditKey(last, entry.at, suffix)
193
+ keys.set(entry, last)
194
+ })
195
+ return keys
196
+ }
197
+
198
+ /** The agent StateStore files: per-user collections and claim namespaces, from the ids each file repeats. */
199
+ function agentFiles(root: string): { collections: { userID: string; collection: string }[]; claims: { namespace: string; keys: string[] }[] } {
200
+ const collections: { userID: string; collection: string }[] = []
201
+ const claims: { namespace: string; keys: string[] }[] = []
202
+ const json = (dir: string) => (existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith(".json")).sort().map((name) => path.join(dir, name)) : [])
203
+ const users = path.join(root, "users")
204
+ const userDirs = existsSync(users) ? readdirSync(users, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() : []
205
+ for (const dir of userDirs) {
206
+ for (const file of json(path.join(users, dir))) {
207
+ const { userID, collection } = header(file)
208
+ if (typeof userID !== "string" || typeof collection !== "string") throw new Error(`Invalid ${file}: no userID or collection. Fix or move it away.`)
209
+ collections.push({ userID, collection })
210
+ }
211
+ }
212
+ for (const file of json(path.join(root, "claims"))) {
213
+ const { namespace, items } = header(file)
214
+ if (typeof namespace !== "string" || !Array.isArray(items)) throw new Error(`Invalid ${file}: no namespace or items. Fix or move it away.`)
215
+ claims.push({ namespace, keys: items.map((item) => (item as { key?: unknown })?.key).filter((key): key is string => typeof key === "string") })
216
+ }
217
+ return { collections, claims }
218
+ }
219
+
220
+ function header(file: string): Record<string, unknown> {
221
+ try {
222
+ const raw = JSON.parse(readFileSync(file, "utf8")) as unknown
223
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) return raw as Record<string, unknown>
224
+ } catch {
225
+ // Reported below without the content.
226
+ }
227
+ throw new Error(`Invalid ${file}: not a JSON object. Fix or move it away.`)
228
+ }
229
+
230
+ function rowItem(target: StateStore, label: string, userID: string, collection: string, key: string, value: unknown): Item {
231
+ return {
232
+ collection: label,
233
+ label: `${label} ${userID}/${key}`,
234
+ async inspect() {
235
+ const current = await target.get(userID, collection, key)
236
+ if (!current) return { state: "absent" }
237
+ return canonical(current.value) === canonical(value) ? { state: "same" } : { state: "differs", version: current.version }
238
+ },
239
+ async create() {
240
+ return (await target.put(userID, collection, key, value, { ifVersion: 0 })).ok
241
+ },
242
+ async overwrite(seen) {
243
+ return (await target.put(userID, collection, key, value, { ifVersion: seen.version })).ok
244
+ },
245
+ }
246
+ }
247
+
248
+ function claimItem(target: StateStore, namespace: string, key: string, owner: string): Item {
249
+ return {
250
+ collection: `claims:${namespace}`,
251
+ label: `claims:${namespace} ${key}`,
252
+ async inspect() {
253
+ const current = await target.claimOf(namespace, key)
254
+ if (!current) return { state: "absent" }
255
+ return current.owner === owner ? { state: "same" } : { state: "differs", version: 0, owner: current.owner }
256
+ },
257
+ async create() {
258
+ return (await target.claim(namespace, key, owner)).ok
259
+ },
260
+ async overwrite(seen) {
261
+ if (!(await target.release(namespace, key, seen.owner!))) return false
262
+ return (await target.claim(namespace, key, owner)).ok
263
+ },
264
+ }
265
+ }
266
+
267
+ /** JSON with object keys sorted (Postgres jsonb does not keep their order). */
268
+ function canonical(value: unknown): string {
269
+ const sort = (v: unknown): unknown => {
270
+ if (Array.isArray(v)) return v.map(sort)
271
+ if (typeof v === "object" && v !== null) {
272
+ return Object.fromEntries(
273
+ Object.keys(v)
274
+ .sort()
275
+ .map((k) => [k, sort((v as Record<string, unknown>)[k])]),
276
+ )
277
+ }
278
+ return v
279
+ }
280
+ return JSON.stringify(sort(JSON.parse(JSON.stringify(value ?? null))))
281
+ }
282
+
283
+ /** The report as lines for the terminal (no secrets: key ids only). */
284
+ export function formatImportReport(report: ImportReport): string[] {
285
+ const lines = [
286
+ `Import from ${report.stateDir}${report.dryRun ? " (dry run: nothing written)" : ""}${report.force ? " with --force" : ""}`,
287
+ ` state.json: ${report.stateFile ? "read" : "none"}; agent files: ${report.agentFiles}`,
288
+ ]
289
+ const columns = ["rows", "new", "same", "conflict", "overwritten", "failed"] as const
290
+ const names = Object.keys(report.collections)
291
+ const width = Math.max(10, ...names.map((name) => name.length))
292
+ const row = (name: string, cells: readonly (string | number)[]) => ` ${name.padEnd(width)} ${cells.map((cell) => String(cell).padStart(12)).join("")}`
293
+ lines.push(row("collection", columns))
294
+ const totals = [0, 0, 0, 0, 0, 0]
295
+ for (const name of names) {
296
+ const c = report.collections[name]!
297
+ const cells = [c.rows, c.created, c.unchanged, c.conflicts, c.overwritten, c.failed]
298
+ cells.forEach((n, i) => (totals[i]! += n))
299
+ lines.push(row(name, cells))
300
+ }
301
+ lines.push(row("total", totals))
302
+ if (report.conflicts.length) {
303
+ lines.push(`Conflicts (rows already there with other contents; ${report.force ? "overwritten" : "skipped, rerun with --force to overwrite"}):`)
304
+ for (const conflict of report.conflicts) lines.push(` ${conflict}`)
305
+ }
306
+ if (report.failures.length) {
307
+ lines.push("Failed:")
308
+ for (const failure of report.failures) lines.push(` ${failure.row}: ${failure.error}`)
309
+ }
310
+ if (report.tokenKeys) {
311
+ lines.push(
312
+ `Signing keys (token-keys.json): ${report.tokenKeys.kids.join(", ")} (active ${report.tokenKeys.active}); not stored. Put them in the HUB_TOKEN_KEYS Secret so signed-in sessions stay valid: rerun with --print-token-keys to print the value.`,
313
+ )
314
+ } else {
315
+ lines.push("Signing keys: no token-keys.json; set a new HUB_TOKEN_KEYS (browsers and apps sign in again; machines and devices keep their tokens).")
316
+ }
317
+ return lines
318
+ }
@@ -3,19 +3,58 @@
3
3
  import type { WebAssets } from "./assets"
4
4
  import { PAIR_TTL_MS } from "./auth"
5
5
  import { isLoopbackURL } from "../loopback"
6
- import { loadCentralConfig, type CentralConfig } from "./config"
6
+ import { SQL } from "bun"
7
+ import { homedir } from "node:os"
8
+ import type { StateStore } from "./backend"
9
+ import { migrate, type MigrateResult } from "./backend/postgres-migrate"
10
+ import { PostgresStateStore } from "./backend/postgres-state"
11
+ import { hubConfigPath, loadCentralConfig, readHubFile, type CentralConfig } from "./config"
7
12
  import { startCentral } from "./http"
13
+ import { formatImportReport, importState, tokenKeysMaterial } from "./import"
8
14
 
9
- export type HubArgs = { readonly action: "run" | "version" | "help" } | { readonly action: "error"; readonly message: string }
15
+ export interface ImportArgs {
16
+ readonly stateDir: string
17
+ readonly dryRun: boolean
18
+ readonly force: boolean
19
+ readonly printTokenKeys: boolean
20
+ }
21
+
22
+ export type HubArgs =
23
+ | { readonly action: "run" | "version" | "help" | "migrate" }
24
+ | ({ readonly action: "import" } & ImportArgs)
25
+ | { readonly action: "error"; readonly message: string }
10
26
 
11
27
  export function parseHubArgs(argv: readonly string[]): HubArgs {
12
28
  if (argv.length === 0) return { action: "run" }
13
29
  const [first] = argv
14
30
  if (argv.length === 1 && (first === "--version" || first === "-v")) return { action: "version" }
15
31
  if (argv.length === 1 && (first === "--help" || first === "-h")) return { action: "help" }
32
+ if (argv.length === 1 && first === "migrate") return { action: "migrate" }
33
+ if (first === "import") return parseImportArgs(argv.slice(1))
16
34
  return { action: "error", message: `Unknown argument: ${argv.join(" ")}. The hub is configured with env vars and hub.json; see --help.` }
17
35
  }
18
36
 
37
+ function parseImportArgs(argv: readonly string[]): HubArgs {
38
+ const usage = "Usage: hub-server import --state <dir> [--dry-run] [--force] [--print-token-keys]"
39
+ let stateDir: string | undefined
40
+ const flags = { dryRun: false, force: false, printTokenKeys: false }
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const arg = argv[i]!
43
+ const inline = arg.startsWith("--state=") ? arg.slice("--state=".length) : undefined
44
+ if (arg === "--state" || inline !== undefined) {
45
+ const value = inline ?? argv[++i]
46
+ if (!value || value.startsWith("--")) return { action: "error", message: `--state needs a directory. ${usage}` }
47
+ if (stateDir !== undefined) return { action: "error", message: `--state given twice. ${usage}` }
48
+ stateDir = value
49
+ } else if (arg === "--dry-run") flags.dryRun = true
50
+ else if (arg === "--force") flags.force = true
51
+ else if (arg === "--print-token-keys") flags.printTokenKeys = true
52
+ else return { action: "error", message: `Unknown import argument: ${arg}. ${usage}` }
53
+ }
54
+ if (stateDir === undefined) return { action: "error", message: `import needs --state <dir> (the single-process hub's state directory). ${usage}` }
55
+ return { action: "import", stateDir, ...flags }
56
+ }
57
+
19
58
  export function hubHelp(version: string): string {
20
59
  return `hub-server ${version}
21
60
 
@@ -23,6 +62,8 @@ Hub: the central hub your AI agent harnesses connect to (OpenCode first). Serves
23
62
  pairs phones and browsers, enrolls machines, and does push notifications and (optional) Voice speech.
24
63
 
25
64
  Usage: hub-server [--help | --version] (also installed as opencode-hub and opencode-voice-hub)
65
+ hub-server migrate
66
+ hub-server import --state <dir> [--dry-run] [--force] [--print-token-keys]
26
67
 
27
68
  Configuration: environment variables over ~/.config/hub/hub.json ($HUB_CONFIG).
28
69
  HUB_HOST / HUB_PORT listen address (default 127.0.0.1 / 4097)
@@ -40,6 +81,14 @@ Multi-user (sign in with Google or another OpenID Connect provider):
40
81
  HUB_ADMIN_EMAILS admin emails, comma separated (at least one)
41
82
  Register <public URL>/auth/callback as the redirect URI.
42
83
 
84
+ Cluster mode (several pods on Postgres + Valkey; docs/cluster.md):
85
+ HUB_DATABASE_URL / HUB_VALKEY_URL postgres:// and redis:// URLs (both, or hub.json cluster)
86
+ migrate applies the Postgres schema (idempotent, under an advisory lock); run it before the pods
87
+ import --state <dir> copies a single-process hub's state directory (state.json, agent/) into Postgres;
88
+ idempotent: existing rows are kept (conflicts listed) unless --force; --dry-run writes
89
+ nothing; --print-token-keys prints token-keys.json as the HUB_TOKEN_KEYS value (secret)
90
+ on stdout. Stop the single-process hub first. Machines and devices keep their tokens.
91
+
43
92
  The former VOICE_* names (opencode-voice) still work, with a deprecation warning; so do the
44
93
  opencode-hub and opencode-voice config and state directories (copied once on first start).
45
94
  While no device is paired, the log shows a pairing code for the first one (open <public URL>/pair).
@@ -86,6 +135,12 @@ export function runHub(options: RunHubOptions): void {
86
135
  console.error(`[hub] ${args.message}`)
87
136
  process.exit(2)
88
137
  }
138
+ if (args.action === "migrate" || args.action === "import") {
139
+ const io: CommandIO = { env: process.env, out: (line) => console.log(line), err: (line) => console.error(line) }
140
+ const run = args.action === "import" ? runImport(args, io) : runMigrate(io)
141
+ void run.then((code) => process.exit(code))
142
+ return
143
+ }
89
144
 
90
145
  let loaded: ReturnType<typeof loadCentralConfig>
91
146
  try {
@@ -122,15 +177,159 @@ export function runHub(options: RunHubOptions): void {
122
177
  }, PAIR_TTL_MS - 5_000)
123
178
  }
124
179
 
125
- let stopping = false
126
- const shutdown = async (signal: string) => {
127
- if (stopping) return
128
- stopping = true
129
- console.log(`[hub] ${signal}: shutting down`)
130
- clearInterval(timer)
131
- await central.stop()
132
- process.exit(0)
180
+ const onSignal = shutdownOnSignals(central, {
181
+ log: (line) => {
182
+ clearInterval(timer)
183
+ console.log(line)
184
+ },
185
+ exit: (code) => process.exit(code),
186
+ })
187
+ process.on("SIGTERM", () => void onSignal("SIGTERM"))
188
+ process.on("SIGINT", () => void onSignal("SIGINT"))
189
+ }
190
+
191
+ /**
192
+ * The hub's signal handler: the first SIGTERM/SIGINT shuts down (shutdownHub) and exits 0; a second one while that is
193
+ * still running (a drain waiting for requests) stops at once and exits 1; later ones are ignored.
194
+ */
195
+ export function shutdownOnSignals(
196
+ central: { drain(): Promise<void>; stop(): Promise<void> },
197
+ options: { readonly log: (line: string) => void; readonly exit: (code: number) => void },
198
+ ): (signal: string) => Promise<void> {
199
+ let state: "running" | "stopping" | "forced" = "running"
200
+ return async (signal) => {
201
+ if (state === "forced") return
202
+ if (state === "stopping") {
203
+ state = "forced"
204
+ options.log(`[hub] ${signal} again: stopping now`)
205
+ await central.stop().catch((cause: unknown) => options.log(`[hub] stop failed: ${(cause as Error)?.message ?? cause}`))
206
+ options.exit(1)
207
+ return
208
+ }
209
+ state = "stopping"
210
+ // Once forced, the drain ending later neither stops again nor exits.
211
+ const once = { drain: () => central.drain(), stop: () => (state === "forced" ? Promise.resolve() : central.stop()) }
212
+ await shutdownHub(once, signal, options.log)
213
+ if (state === "stopping") options.exit(0)
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Shuts the hub down on a signal. SIGTERM (Kubernetes replacing the pod, systemd, docker stop) drains first (design
219
+ * §10: /readyz fails, spokes and nodes are sent to another pod, requests in flight finish; quick without any), then
220
+ * stops; SIGINT (Ctrl-C) stops at once. A failed drain still stops.
221
+ */
222
+ export async function shutdownHub(central: { drain(): Promise<void>; stop(): Promise<void> }, signal: string, log: (line: string) => void = console.log): Promise<void> {
223
+ if (signal === "SIGTERM") {
224
+ log(`[hub] ${signal}: draining`)
225
+ try {
226
+ await central.drain()
227
+ } catch (cause) {
228
+ log(`[hub] drain failed: ${cause instanceof Error ? cause.message : cause}`)
229
+ }
230
+ }
231
+ log(`[hub] ${signal}: shutting down`)
232
+ await central.stop()
233
+ }
234
+
235
+
236
+ /** Where the one-shot commands (migrate, import) read settings and write lines. */
237
+ export interface CommandIO {
238
+ readonly env: Readonly<Record<string, string | undefined>>
239
+ readonly out: (line: string) => void
240
+ readonly err: (line: string) => void
241
+ /** For the hub.json path (default the user's home). */
242
+ readonly home?: string
243
+ }
244
+
245
+ /**
246
+ * The Postgres URL of the one-shot commands: HUB_DATABASE_URL, else hub.json cluster.database. Errors name the
247
+ * setting, never the value (it holds the password).
248
+ */
249
+ function databaseURL(io: CommandIO): string {
250
+ const name = "HUB_DATABASE_URL (cluster.database)"
251
+ const url = io.env.HUB_DATABASE_URL?.trim() || readHubFile(hubConfigPath(io.env, io.home ?? homedir()), io.home ?? homedir()).file?.cluster?.database
252
+ if (!url) throw new Error(`${name} is not set: the Postgres database of the cluster`)
253
+ let protocol: string
254
+ try {
255
+ protocol = new URL(url).protocol
256
+ } catch {
257
+ throw new Error(`${name} is not a valid URL`)
258
+ }
259
+ if (protocol !== "postgres:" && protocol !== "postgresql:") throw new Error(`${name} must be a postgres:// or postgresql:// URL`)
260
+ return url
261
+ }
262
+
263
+ /** A message without the URL or its secret-ish parts (password, host). */
264
+ function redact(message: string, url: string | undefined): string {
265
+ if (!url) return message
266
+ let out = message.split(url).join("<database>")
267
+ try {
268
+ const parsed = new URL(url)
269
+ for (const part of [parsed.password, decodeURIComponent(parsed.password), parsed.host, parsed.hostname]) {
270
+ if (part && part.length >= 3) out = out.split(part).join("<redacted>")
271
+ }
272
+ } catch {
273
+ // Not a URL: nothing more to hide.
274
+ }
275
+ return out
276
+ }
277
+
278
+ /** `hub-server migrate`: applies the Postgres schema (idempotent, under the advisory lock). Returns the exit code. */
279
+ export async function runMigrate(io: CommandIO & { readonly migrate?: (url: string) => Promise<MigrateResult> }): Promise<number> {
280
+ let url: string | undefined
281
+ try {
282
+ url = databaseURL(io)
283
+ const result = await (io.migrate ?? migrateDatabase)(url)
284
+ io.out(
285
+ result.applied.length
286
+ ? `[hub] migrate: applied ${result.applied.join(", ")}; schema now at version ${result.version}`
287
+ : `[hub] migrate: schema up to date at version ${result.version}`,
288
+ )
289
+ return 0
290
+ } catch (cause) {
291
+ io.err(`[hub] migrate failed: ${redact((cause as Error)?.message ?? String(cause), url)}`)
292
+ return 1
293
+ }
294
+ }
295
+
296
+ async function migrateDatabase(url: string): Promise<MigrateResult> {
297
+ const sql = new SQL(url, { max: 1 })
298
+ try {
299
+ return await migrate(sql)
300
+ } finally {
301
+ await sql.close().catch(() => {})
302
+ }
303
+ }
304
+
305
+ /**
306
+ * `hub-server import`: the state directory into Postgres (see ./import). The report goes to stdout, or to stderr with
307
+ * --print-token-keys, so stdout holds only the HUB_TOKEN_KEYS value. Returns the exit code (1 when a row failed).
308
+ */
309
+ export async function runImport(
310
+ args: ImportArgs,
311
+ io: CommandIO & { readonly openState?: (url: string) => StateStore & { close?(): Promise<void> } },
312
+ ): Promise<number> {
313
+ const report = args.printTokenKeys ? io.err : io.out
314
+ let url: string | undefined
315
+ let state: (StateStore & { close?(): Promise<void> }) | undefined
316
+ try {
317
+ url = databaseURL(io)
318
+ state = (io.openState ?? ((u: string) => new PostgresStateStore({ url: u, max: 2 })))(url)
319
+ // Reachable and migrated (a schema error says to run migrate), before any row is read or counted.
320
+ await state.ping?.()
321
+ const result = await importState({ stateDir: args.stateDir, state, dryRun: args.dryRun, force: args.force })
322
+ for (const line of formatImportReport(result)) report(redact(line, url))
323
+ if (args.printTokenKeys) {
324
+ const material = tokenKeysMaterial(args.stateDir)
325
+ if (material) io.out(material)
326
+ else io.err("[hub] no token-keys.json: nothing to print")
327
+ }
328
+ return result.failed ? 1 : 0
329
+ } catch (cause) {
330
+ io.err(`[hub] import failed: ${redact((cause as Error)?.message ?? String(cause), url)}`)
331
+ return 1
332
+ } finally {
333
+ await state?.close?.().catch(() => {})
133
334
  }
134
- process.on("SIGTERM", () => void shutdown("SIGTERM"))
135
- process.on("SIGINT", () => void shutdown("SIGINT"))
136
335
  }