@huynle/hub 0.6.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -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 +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,419 @@
1
+ // Placement (design §6.2, §9; decision znbg8xa5): which of the user's nodes takes a launch.
2
+ // - matchNode is pure: over a snapshot of candidates it applies the hard constraints (the user's own machines only,
3
+ // not draining, the runtime kind, the harness listed for that runtime, the runtime profile when one is named,
4
+ // the workspace under one of the node's roots, for host launches a connected service spoke of the launch's harness
5
+ // announcing "launch" (each harness has its own service spoke: a Claude Code one never serves an OpenCode launch),
6
+ // and for openshell launches the node feature "sandbox" (it starts and stops the harness itself; the sandbox's
7
+ // launch-bound spoke creates the session, so no service spoke is needed)), then the placement's machine and labels (mode require: hard; prefer: ranking only), then
8
+ // free slots (capacity minus used minus reservations). The best node has the preference, then most free slots,
9
+ // then the lowest machine ID. A launch that cannot be placed waits for capacity (a fitting node is full) or for a
10
+ // match (no node fits; the reason names each node's first problem).
11
+ // - Admission (per user, hub.json agents.launches): admit() refuses a new launch with rate_limited while maxQueued
12
+ // launches are queued or perHour were recorded this clock hour (Counters, one key per hour); it consumes
13
+ // nothing, so the engine calls recordLaunch() only when the store really created a launch (not for an
14
+ // idempotent retry). Two racing creates can both pass admit: perHour is a soft cap by that margin.
15
+ // - place(user) is one pass under the user's fenced lease (Locks `placement:<user>`), so two processes never place
16
+ // the same user's launches; the lease is renewed (and its fence checked) before every write and released at
17
+ // the end. A pass first returns launches left placing by a dead holder to the queue, then walks queued launches
18
+ // oldest first: while fewer than maxActive are placing/provisioning/running, each is matched against the
19
+ // user's nodes and moved queued → placing → provisioning on the machine. A launch that cannot be placed now is
20
+ // reported as waiting and does not hold back later ones; earlier launches always pick first. Reservations are
21
+ // launches provisioning or running on a machine that its node does not report (yet); slots taken in the pass
22
+ // count at once. Reasons go on the launch only when it moves; waiting reasons are returned for the engine.
23
+ // - PlacementLoop runs passes: kick(user) when something changed (a launch queued or ended, a node connected or
24
+ // reported capacity, a service spoke came), coalescing kicks during a pass into one more, plus a sweep over
25
+ // users() every intervalMs for whatever a kick missed (e.g. another process held the lease). Each placed launch
26
+ // goes to onPlaced (the launch engine starts provisioning); failures go to onError and never stop the loop.
27
+ // - Node and spoke info come through PlacementNodes / PlacementSpokes, which the launch engine adapts from the
28
+ // NodeRegistry and the Registry; memory is not matched (a spec has no memory need yet).
29
+ import type { NodeCapacity, NodeRuntimeInfo } from "../../protocol"
30
+ import type { Clock, Counters, Locks, Timers } from "../backend/types"
31
+ import type { HubLaunches } from "../config"
32
+ import { AgentError } from "./errors"
33
+ import type { LaunchSpec } from "./launch-spec"
34
+ import { type Launch, type LaunchState, type LaunchStore, LaunchTransitionError } from "./launch-store"
35
+
36
+ /** A connected node as placement sees it (the launch engine adapts the NodeRegistry to this). */
37
+ export interface PlacementNode {
38
+ readonly machineID: string
39
+ /** The machine's name (placement.machine matches it or the ID). */
40
+ readonly name?: string
41
+ /** The user owning the machine. */
42
+ readonly owner: string
43
+ readonly runtimes: readonly NodeRuntimeInfo[]
44
+ readonly labels: Readonly<Record<string, string>>
45
+ readonly capacity: NodeCapacity
46
+ readonly used: NodeCapacity
47
+ readonly workspaceRoots: readonly string[]
48
+ readonly draining: boolean
49
+ /** The launches the node reports carrying (they count in `used`). */
50
+ readonly launchIDs: readonly string[]
51
+ /** Optional node features from its hello (e.g. "sandbox"); missing: none. */
52
+ readonly features?: readonly string[]
53
+ }
54
+
55
+ /** A node with what the matcher needs besides its own report. */
56
+ export interface Candidate extends PlacementNode {
57
+ /** The harnesses whose connected service spoke on the machine announces "launch" (host launches need theirs). */
58
+ readonly launchHarnesses: readonly string[]
59
+ /** Slots taken by launches placed on it that the node does not report yet. */
60
+ readonly reserved: number
61
+ }
62
+
63
+ export type Match =
64
+ | { readonly ok: true; readonly node: PlacementNode; readonly preferred: boolean }
65
+ | { readonly ok: false; readonly wait: "capacity" | "no_match"; readonly reason: string }
66
+
67
+ const label = (node: PlacementNode) => node.name ?? node.machineID
68
+
69
+ const underRoot = (repo: string, root: string): boolean => {
70
+ const base = root.replace(/\/+$/, "")
71
+ return repo === base || repo.startsWith(`${base}/`)
72
+ }
73
+
74
+ /** Why the node can never take the launch as it is now (undefined: it can, given a free slot). */
75
+ function hardProblem(spec: LaunchSpec, node: Candidate): string | undefined {
76
+ if (node.draining) return "is draining"
77
+ const kind = spec.runtime.kind
78
+ const runtime = node.runtimes.find((r) => r.kind === kind)
79
+ if (!runtime) return `has no ${kind} runtime`
80
+ if (!runtime.harnesses?.includes(spec.harness)) return `has no ${spec.harness} harness`
81
+ if (spec.runtime.profile !== undefined && !runtime.profiles?.includes(spec.runtime.profile)) return `has no ${spec.runtime.profile} profile`
82
+ if (!node.workspaceRoots.some((root) => underRoot(spec.workspace.repo, root))) return `does not serve ${spec.workspace.repo}`
83
+ if (kind === "host" && !node.launchHarnesses.includes(spec.harness)) {
84
+ return node.launchHarnesses.length ? `has no connected ${spec.harness} service spoke with launch` : "has no connected service spoke with launch"
85
+ }
86
+ if (kind === "openshell" && !node.features?.includes("sandbox")) return "does not serve sandboxes"
87
+ return undefined
88
+ }
89
+
90
+ /** Why the node does not match the placement's machine and labels (undefined: it does). */
91
+ function placementProblem(spec: LaunchSpec, node: PlacementNode): string | undefined {
92
+ const { machine, labels } = spec.placement
93
+ if (machine !== undefined && machine !== node.machineID && machine !== node.name) return `is not ${machine}`
94
+ for (const [key, value] of Object.entries(labels)) if (node.labels[key] !== value) return `lacks label ${key}=${value}`
95
+ return undefined
96
+ }
97
+
98
+ const freeSlots = (node: Candidate) => node.capacity.slots - node.used.slots - node.reserved
99
+
100
+ /** Picks the user's node for a launch from a snapshot of candidates, or says why it must wait. Pure. */
101
+ export function matchNode(userID: string, spec: LaunchSpec, candidates: readonly Candidate[]): Match {
102
+ const own = candidates.filter((node) => node.owner === userID)
103
+ if (own.length === 0) return { ok: false, wait: "no_match", reason: "No node is connected" }
104
+ const require = spec.placement.mode === "require"
105
+ const problems: string[] = []
106
+ const full: Candidate[] = []
107
+ const free: { node: Candidate; preferred: boolean }[] = []
108
+ for (const node of own) {
109
+ const placement = placementProblem(spec, node)
110
+ const problem = hardProblem(spec, node) ?? (require ? placement : undefined)
111
+ if (problem) problems.push(`${label(node)} ${problem}`)
112
+ else if (freeSlots(node) <= 0) full.push(node)
113
+ else free.push({ node, preferred: placement === undefined })
114
+ }
115
+ if (free.length > 0) {
116
+ free.sort(
117
+ (a, b) =>
118
+ Number(b.preferred) - Number(a.preferred) ||
119
+ freeSlots(b.node) - freeSlots(a.node) ||
120
+ (a.node.machineID < b.node.machineID ? -1 : a.node.machineID > b.node.machineID ? 1 : 0),
121
+ )
122
+ const best = free[0]!
123
+ return { ok: true, node: best.node, preferred: best.preferred }
124
+ }
125
+ if (full.length > 0) return { ok: false, wait: "capacity", reason: `Waiting for a free slot on ${full.map(label).join(", ")}` }
126
+ return { ok: false, wait: "no_match", reason: `No node fits: ${problems.join("; ")}` }
127
+ }
128
+
129
+
130
+ // ---- admission and the placement pass ---------------------------------------------------------------------
131
+
132
+ /** Per-user launch caps (hub.json agents.launches). */
133
+ export interface LaunchCaps {
134
+ /** Launches waiting to be placed. */
135
+ readonly maxQueued: number
136
+ /** Launches placed and not ended (placing, provisioning, running). */
137
+ readonly maxActive: number
138
+ /** New launches per clock hour. */
139
+ readonly perHour: number
140
+ }
141
+
142
+ export const DEFAULT_LAUNCH_CAPS: LaunchCaps = { maxQueued: 50, maxActive: 4, perHour: 60 }
143
+ export const PLACEMENT_LEASE_MS = 15_000
144
+ const HOUR_MS = 3_600_000
145
+ /** retryAfter (seconds) when the queue is full: placement frees it as nodes do. */
146
+ const QUEUE_RETRY_S = 60
147
+ const ACTIVE: readonly LaunchState[] = ["placing", "provisioning", "running"]
148
+
149
+ /** The user's connected nodes (the launch engine adapts the NodeRegistry). */
150
+ export interface PlacementNodes {
151
+ nodes(userID: string): readonly PlacementNode[] | Promise<readonly PlacementNode[]>
152
+ }
153
+
154
+ /** The service spokes (the launch engine adapts the Registry). */
155
+ export interface PlacementSpokes {
156
+ /** The harnesses whose connected service spoke on the machine announces the "launch" feature. */
157
+ launchHarnesses(userID: string, machineID: string): readonly string[] | Promise<readonly string[]>
158
+ }
159
+
160
+ export interface PlacementDeps {
161
+ readonly store: LaunchStore
162
+ readonly nodes: PlacementNodes
163
+ readonly spokes: PlacementSpokes
164
+ readonly counters: Counters
165
+ readonly locks: Locks
166
+ /** This process (pod) as lease owner. */
167
+ readonly owner: string
168
+ readonly caps?: HubLaunches
169
+ readonly now?: Clock
170
+ readonly leaseMs?: number
171
+ }
172
+
173
+ /** A launch the pass placed: it is now provisioning on the node's machine. */
174
+ export interface Placed {
175
+ readonly userID: string
176
+ readonly launch: Launch
177
+ readonly node: PlacementNode
178
+ readonly preferred: boolean
179
+ /** The placement lease's fencing token for this pass. */
180
+ readonly fence: number
181
+ }
182
+
183
+ export interface Waiting {
184
+ readonly launchID: string
185
+ /** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
186
+ readonly wait: "capacity" | "no_match" | "active"
187
+ readonly reason: string
188
+ }
189
+
190
+ /** One pass. leased false: another holder has the user's lease, or it was lost mid-pass (placed: before that). */
191
+ export interface PassResult {
192
+ readonly leased: boolean
193
+ readonly placed: Placed[]
194
+ readonly waiting: Waiting[]
195
+ }
196
+
197
+ export const placementLeaseKey = (userID: string) => `placement:${userID}`
198
+
199
+ export class Placement {
200
+ readonly caps: LaunchCaps
201
+ private readonly now: Clock
202
+ private readonly leaseMs: number
203
+
204
+ constructor(private readonly deps: PlacementDeps) {
205
+ this.caps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
206
+ this.now = deps.now ?? Date.now
207
+ this.leaseMs = deps.leaseMs ?? PLACEMENT_LEASE_MS
208
+ }
209
+
210
+ /** Throws rate_limited when the user may not add a launch now (maxQueued, perHour). Consumes nothing. */
211
+ async admit(userID: string): Promise<void> {
212
+ const queued = (await this.deps.store.list(userID, { states: ["queued"] })).length
213
+ if (queued >= this.caps.maxQueued) {
214
+ throw new AgentError("rate_limited", `${queued} launches are queued (the most per user)`, {
215
+ hint: "Wait for queued launches to be placed, or stop some",
216
+ extra: { retryAfter: QUEUE_RETRY_S },
217
+ })
218
+ }
219
+ const { key, endsAt } = this.hour(userID)
220
+ const count = await this.deps.counters.get(key)
221
+ if (count >= this.caps.perHour) {
222
+ throw new AgentError("rate_limited", `${count} launches this hour (the most per user)`, {
223
+ extra: { retryAfter: Math.ceil((endsAt - this.now()) / 1000) },
224
+ })
225
+ }
226
+ }
227
+
228
+ /** Counts a new launch against perHour (call it when the store created one, not for an idempotent retry). */
229
+ async recordLaunch(userID: string): Promise<void> {
230
+ const { key, endsAt } = this.hour(userID)
231
+ await this.deps.counters.incr(key, endsAt - this.now())
232
+ }
233
+
234
+ /** One placement pass for the user under the user's lease: queued launches in order onto fitting nodes. */
235
+ async place(userID: string): Promise<PassResult> {
236
+ const { locks, owner, store } = this.deps
237
+ const key = placementLeaseKey(userID)
238
+ const lease = await locks.acquire(key, owner, this.leaseMs)
239
+ const placed: Placed[] = []
240
+ const waiting: Waiting[] = []
241
+ if (!lease) return { leased: false, placed, waiting }
242
+ const held = async () => (await locks.renew(key, owner, this.leaseMs))?.fence === lease.fence
243
+ const lost = (): PassResult => ({ leased: false, placed, waiting: [] })
244
+ try {
245
+ let launches = await store.list(userID)
246
+ // Under the lease, a launch still placing was left by a holder that died mid-pass.
247
+ for (const launch of launches.filter((l) => l.state === "placing")) {
248
+ if (!(await held())) return lost()
249
+ await skipGone(store.transition(userID, launch.id, "queued", { reason: "Placement interrupted; queued again", from: ["placing"] }))
250
+ }
251
+ launches = await store.list(userID)
252
+ const queued = launches.filter((l) => l.state === "queued")
253
+ if (queued.length === 0) return { leased: true, placed, waiting }
254
+ let active = launches.filter((l) => ACTIVE.includes(l.state)).length
255
+ const candidates = await this.candidates(userID, launches)
256
+ for (const launch of queued) {
257
+ if (active >= this.caps.maxActive) {
258
+ waiting.push({ launchID: launch.id, wait: "active", reason: `Waiting: ${active} launches are active (the most per user)` })
259
+ continue
260
+ }
261
+ const match = matchNode(userID, launch.spec, candidates)
262
+ if (!match.ok) {
263
+ waiting.push({ launchID: launch.id, wait: match.wait, reason: match.reason })
264
+ continue
265
+ }
266
+ const name = match.node.name ?? match.node.machineID
267
+ if (!(await held())) return lost()
268
+ const placing = await skipGone(store.transition(userID, launch.id, "placing", { reason: `Placing on ${name}`, from: ["queued"] }))
269
+ if (!placing) continue
270
+ if (!(await held())) return lost()
271
+ const provisioning = await store.transition(userID, launch.id, "provisioning", {
272
+ reason: `Placed on ${name}${match.preferred ? "" : " (preferred placement not available)"}`,
273
+ machineID: match.node.machineID,
274
+ from: ["placing"],
275
+ })
276
+ const index = candidates.findIndex((c) => c.machineID === match.node.machineID)
277
+ candidates[index] = { ...candidates[index]!, reserved: candidates[index]!.reserved + 1 }
278
+ active++
279
+ placed.push({ userID, launch: provisioning, node: match.node, preferred: match.preferred, fence: lease.fence })
280
+ }
281
+ return { leased: true, placed, waiting }
282
+ } finally {
283
+ await locks.release(key, owner)
284
+ }
285
+ }
286
+
287
+ /** The user's nodes with their service spoke and reservations (placed launches the node does not report yet). */
288
+ private async candidates(userID: string, launches: readonly Launch[]): Promise<Candidate[]> {
289
+ const nodes = (await this.deps.nodes.nodes(userID)).filter((node) => node.owner === userID)
290
+ return Promise.all(
291
+ nodes.map(async (node) => ({
292
+ ...node,
293
+ launchHarnesses: await this.deps.spokes.launchHarnesses(userID, node.machineID),
294
+ reserved: launches.filter((l) => l.machineID === node.machineID && ACTIVE.includes(l.state) && !node.launchIDs.includes(l.id)).length,
295
+ })),
296
+ )
297
+ }
298
+
299
+ private hour(userID: string): { key: string; endsAt: number } {
300
+ const bucket = Math.floor(this.now() / HOUR_MS)
301
+ return { key: `launches:hour:${userID}:${bucket}`, endsAt: (bucket + 1) * HOUR_MS }
302
+ }
303
+ }
304
+
305
+ /** A step that lost a race with the launch ending (or moving on): undefined, not an error. */
306
+ async function skipGone<T>(step: Promise<T>): Promise<T | undefined> {
307
+ try {
308
+ return await step
309
+ } catch (cause) {
310
+ if (cause instanceof LaunchTransitionError) return undefined
311
+ throw cause
312
+ }
313
+ }
314
+
315
+
316
+ // ---- the loop ---------------------------------------------------------------------------------------------
317
+
318
+ export const PLACEMENT_SWEEP_MS = 5_000
319
+
320
+ export interface PlacementLoopDeps {
321
+ readonly placement: Pick<Placement, "place">
322
+ /** The users that may have launches to place (e.g. the store's users). */
323
+ readonly users: () => Promise<readonly string[]>
324
+ /** The engine takes a placed (provisioning) launch from here. */
325
+ readonly onPlaced: (placed: Placed) => void | Promise<void>
326
+ readonly onError?: (cause: unknown, userID?: string) => void
327
+ readonly timers?: Timers
328
+ readonly intervalMs?: number
329
+ }
330
+
331
+ const defaultTimers: Timers = { set: (fn, ms) => setTimeout(fn, ms), clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>) }
332
+
333
+ /** Runs placement passes: on kick(user) (a launch queued or ended, a node's capacity changed) and on a sweep. */
334
+ export class PlacementLoop {
335
+ private readonly runs = new Map<string, { again: boolean; done: Promise<void> }>()
336
+ private readonly timers: Timers
337
+ private readonly intervalMs: number
338
+ private readonly onError: (cause: unknown, userID?: string) => void
339
+ private timer: unknown
340
+ private running = false
341
+
342
+ constructor(private readonly deps: PlacementLoopDeps) {
343
+ this.timers = deps.timers ?? defaultTimers
344
+ this.intervalMs = deps.intervalMs ?? PLACEMENT_SWEEP_MS
345
+ this.onError = deps.onError ?? ((cause, userID) => console.error(`[hub] placement failed${userID ? ` for ${userID}` : ""}:`, cause))
346
+ }
347
+
348
+ /** A pass for the user; a kick during a pass makes one more pass after it. Resolves when they are done. */
349
+ kick(userID: string): Promise<void> {
350
+ const current = this.runs.get(userID)
351
+ if (current) {
352
+ current.again = true
353
+ return current.done
354
+ }
355
+ const run = { again: false, done: Promise.resolve() }
356
+ this.runs.set(userID, run)
357
+ run.done = (async () => {
358
+ try {
359
+ do {
360
+ run.again = false
361
+ await this.pass(userID)
362
+ } while (run.again)
363
+ } finally {
364
+ this.runs.delete(userID)
365
+ }
366
+ })()
367
+ return run.done
368
+ }
369
+
370
+ /** Sweeps every intervalMs (idempotent). */
371
+ start(): void {
372
+ this.running = true
373
+ if (this.timer === undefined) this.schedule()
374
+ }
375
+
376
+ stop(): void {
377
+ this.running = false
378
+ if (this.timer !== undefined) this.timers.clear(this.timer)
379
+ this.timer = undefined
380
+ }
381
+
382
+ /** A pass for every user, one after another. */
383
+ async sweep(): Promise<void> {
384
+ let users: readonly string[]
385
+ try {
386
+ users = await this.deps.users()
387
+ } catch (cause) {
388
+ this.onError(cause)
389
+ return
390
+ }
391
+ for (const userID of users) await this.kick(userID)
392
+ }
393
+
394
+ private schedule(): void {
395
+ this.timer = this.timers.set(() => {
396
+ this.timer = undefined
397
+ void this.sweep().finally(() => {
398
+ if (this.running && this.timer === undefined) this.schedule()
399
+ })
400
+ }, this.intervalMs)
401
+ }
402
+
403
+ private async pass(userID: string): Promise<void> {
404
+ let result: PassResult
405
+ try {
406
+ result = await this.deps.placement.place(userID)
407
+ } catch (cause) {
408
+ this.onError(cause, userID)
409
+ return
410
+ }
411
+ for (const placed of result.placed) {
412
+ try {
413
+ await this.deps.onPlaced(placed)
414
+ } catch (cause) {
415
+ this.onError(cause, userID)
416
+ }
417
+ }
418
+ }
419
+ }