@huynle/hub 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,1025 @@
1
+ // The launch engine (design §6, §7, §9; decision znbg8xa5): drives a launch from accept to its end. Per user
2
+ // (tenancy: every call names its user; another user's launch is not_found).
3
+ // - accept: validateLaunchSpec, then (unless it is an idempotent retry of a key) the name must be free (no session
4
+ // holds it and no live launch asks for it: name_taken) and placement admits it (caps: rate_limited); the store
5
+ // creates it queued, the hourly cap counts it, and placement is kicked.
6
+ // - Placement (placement.ts, under the user's fenced lease) moves it to provisioning on a node; the engine then
7
+ // runs one pipeline per launch: prepare on the node (requestNode) → launch create on the machine's service spoke
8
+ // (feature "launch": directory, launchID, agent, model) → roles (byUser only for the user's own launches; a refused
9
+ // role is noted, not fatal) → the name (taken meanwhile: failed) → the primer (Delivery.prime, so roles and name
10
+ // are in it) → the prompt (actions.prompt, resetHops false; marked prompted first, so at most once) → running.
11
+ // A stop between steps ends it stopped; the node going away ends it lost; anything else ends it failed.
12
+ // - openshell launches (design §6.4, decision lk6gn3u7): after prepare the engine mints the launch's hlt1 token
13
+ // (launchTokens), has the node start the harness in its sandbox (start op with the hub's public URL, which the
14
+ // node rewrites for the sandbox, and the token; work record: started) and waits up to LAUNCH_CONNECT_MS for the
15
+ // harness's launch-bound connection (registry.launchConn; a stop ends the wait at once); the launch create goes
16
+ // to THAT connection, never the service spoke. Ending stops the harness on the node (graceful for idle and
17
+ // needs_user, else kill) before collect and cleanup. A gap of the sandbox's spoke is a gap like any other: lost
18
+ // only after SESSION_LOST_MS. Recovery reuses a still-connected harness or asks start again; a sandbox the node
19
+ // reports for a launch the hub has no record of is the launch's own: stopped (when up) and removed.
20
+ // - End watcher (running launches): an idle end only after the prompt was seen working (registry status working,
21
+ // a steer, or the prompt's turn finishing); then idle by real evidence (Registry.idle) with no blockers, none of
22
+ // the session's own asks open and none of its Decisions open, unchanged for END_SETTLE_MS. end.when asked also
23
+ // ends needs_user when a question stays open that long (blocked, or idle with an ask or Decision open); never
24
+ // ends only by stop or timeout. end.timeout counts from running and stops the session (outcome timeout). A
25
+ // session unreported for SESSION_LOST_MS is lost. Registry status events, a tick (END_TICK_MS) and settle/lost
26
+ // timers drive it.
27
+ // - Ending: stop the session when asked (stop, timeout), collect artifacts (once prompted) and clean the workspace
28
+ // up on the node (policy: needs_user keeps; never prompted removes; else spec.end.cleanup; the node never removes
29
+ // reused, dirty or unpushed work), then the ended step with artifacts. Node away: its cleanup waits in the work
30
+ // record and runs when the node reconnects (reconcile).
31
+ // - Work records (StateStore LAUNCH_WORK, per launch): fence, workspace, sessionID, prompted, seenWorking, pending
32
+ // cleanup, so a restarted hub carries on: queued/placing → placement; provisioning → recover (see recover);
33
+ // running → watched again (seen working and the timeout survive).
34
+ // - Cluster mode (design §9, decision vkxkqiuf §7): start() makes the engine the supervisor (placement, pipelines,
35
+ // end watchers, node reconcile); the hub calls it on the leader pod only and shutdown() when the pod loses the
36
+ // lease (its provisioning runs stop at their next step and the next leader recovers them from the work records).
37
+ // accept() works on any pod: a pod that does not supervise kicks the leader (deps.supervisor), and a stop of a
38
+ // provisioning or running launch goes to the leader, where its pipeline or watch lives.
39
+ // - onLaunchChanged gets every change the engine makes (queued, provisioning, running, ended); placement's own
40
+ // intermediate steps (placing, re-queued) are not reported.
41
+ import type { NodeListener, NodeView } from "../nodes"
42
+ import type { ModelSelection, NodeLaunchPhase, NodeLaunchReport, NodeRequest, NodeResultFor, PreparedWorkspace, ServerRequest } from "../../protocol"
43
+ import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
44
+ import type { HubLaunches } from "../config"
45
+ import type { Registry } from "../registry"
46
+ import { type Actions, unsupportedMessage } from "../actions"
47
+ import type { Decisions } from "./decisions"
48
+ import type { Delivery } from "./delivery"
49
+ import type { Directory } from "./directory"
50
+ import { AgentError } from "./errors"
51
+ import { validateLaunchSpec } from "./launch-spec"
52
+ import type { CleanupPolicy, LaunchSpec } from "./launch-spec"
53
+ import { type Launch, type LaunchFilter, type LaunchOutcome, type LaunchState, LaunchStore, LaunchTransitionError } from "./launch-store"
54
+ import { Placement, PlacementLoop, type Placed, type PlacementNode } from "./placement"
55
+
56
+ /** The NodeRegistry calls the engine needs. */
57
+ export type EngineNodes = {
58
+ list(ownerID?: string): NodeView[]
59
+ forMachine(machineID: string): NodeView | undefined
60
+ /** With remote: also other pods' nodes (cluster mode: the supervisor follows every node of the cluster). */
61
+ subscribe(listener: NodeListener, options?: { readonly remote?: boolean }): () => void
62
+ }
63
+ /** Node ops (SpokeHub.requestNode). */
64
+ export interface EngineNodeOps {
65
+ requestNode<R extends NodeRequest>(connID: string, req: R): Promise<NodeResultFor[R["op"]]>
66
+ }
67
+ export type EngineRegistry = Pick<Registry, "serviceFor" | "serviceHarnesses" | "supports" | "connTenant" | "hasSession" | "idle" | "session" | "subscribe" | "onSessions" | "launchConn">
68
+ /** Mints the launch-bound token (hlt1) a sandboxed harness connects back with (LaunchTokens). */
69
+ export interface EngineLaunchTokens {
70
+ mint(machineID: string, launchID: string): Promise<{ readonly token: string; readonly exp: number } | undefined>
71
+ }
72
+ /** Spoke ops (SpokeHub.request). */
73
+ export interface EngineSpokes {
74
+ request(connID: string, req: ServerRequest): Promise<unknown>
75
+ }
76
+ export type EngineDirectory = Pick<Directory, "sync" | "setName" | "claimRole" | "resolve">
77
+ export interface EngineDelivery {
78
+ /** Delivery.prime (whether it admitted a primer does not matter here). */
79
+ prime(userID: string, sessionID: string): Promise<unknown>
80
+ readonly messages: Pick<Delivery["messages"], "inbox">
81
+ }
82
+ export type EngineDecisions = Pick<Decisions, "list">
83
+
84
+ /**
85
+ * Cluster mode: the supervisor (start()) runs on the leader pod only (../cluster/leader.ts); an engine that does not
86
+ * supervise hands it work: a queued launch kicks it, a stop of a provisioning or running launch goes to it (its
87
+ * pipeline and end watcher live there). stop resolves undefined when no supervisor answers: the pod stops it itself.
88
+ */
89
+ export interface EngineSupervisor {
90
+ kick(userID: string): void
91
+ stop(userID: string, id: string, reason: string): Promise<Launch | undefined>
92
+ }
93
+ export type EngineActions = Pick<Actions, "prompt" | "stop">
94
+
95
+ export interface LaunchEngineDeps {
96
+ readonly state: StateStore
97
+ readonly counters: Counters
98
+ readonly locks: Locks
99
+ /** This process (pod), as placement lease owner. */
100
+ readonly owner: string
101
+ readonly caps?: HubLaunches
102
+ readonly nodes: EngineNodes
103
+ readonly nodeOps: EngineNodeOps
104
+ readonly registry: EngineRegistry
105
+ readonly spokes: EngineSpokes
106
+ readonly directory: EngineDirectory
107
+ readonly delivery: EngineDelivery
108
+ readonly decisions: EngineDecisions
109
+ readonly actions: EngineActions
110
+ /** Launch-bound tokens for sandboxed (openshell) launches; without them (or hubURL) such launches fail. */
111
+ readonly launchTokens?: EngineLaunchTokens
112
+ /** The hub's public URL, handed to the node at start (the node rewrites a loopback URL for its sandbox). */
113
+ readonly hubURL?: string
114
+ /** Users that may have launches (restart recovery and the placement sweep). */
115
+ readonly users: () => Promise<readonly string[]> | readonly string[]
116
+ readonly now?: Clock
117
+ readonly timers?: Timers
118
+ /** Every launch change the engine makes (SSE events and webhooks hang off this). */
119
+ readonly onLaunchChanged?: (launch: Launch) => void
120
+ /** Cluster mode: the leader's supervisor, for an engine that does not supervise (see EngineSupervisor). */
121
+ readonly supervisor?: EngineSupervisor
122
+ }
123
+
124
+ /** Node events of every pod (cluster mode; the same as local ones on a single hub). */
125
+ const REMOTE = { remote: true } as const
126
+
127
+ const LIVE: readonly LaunchState[] = ["queued", "placing", "provisioning", "running"]
128
+ /** Engine state per launch beside the store's record (StateStore collection, key: the launch ID). */
129
+ export const LAUNCH_WORK = "launch-work"
130
+
131
+ /** What the engine knows about a launch's run, kept so a restarted hub can carry on. */
132
+ export interface LaunchWork {
133
+ /** The placement lease fence the launch was placed under. */
134
+ readonly fence?: number
135
+ /** The workspace the node prepared (collect and cleanup carry it back). */
136
+ readonly workspace?: PreparedWorkspace
137
+ readonly sessionID?: string
138
+ /** The prompt was handed to the session (at most once). */
139
+ readonly prompted?: boolean
140
+ /** The session was seen working after its prompt (an idle end may start). */
141
+ readonly seenWorking?: boolean
142
+ /** The launch ended while its node was away: the cleanup policy to apply when it comes back. */
143
+ readonly cleanup?: CleanupPolicy
144
+ /** The node was asked to start the launch's harness (openshell): it must be stopped at the end. */
145
+ readonly started?: boolean
146
+ }
147
+
148
+ /** How long a session must stay idle (or asking) with nothing changing before its launch ends. */
149
+ export const END_SETTLE_MS = 30_000
150
+ /** How often running launches are looked at again (asks and Decisions change without registry events). */
151
+ export const END_TICK_MS = 15_000
152
+ /** How long a running launch's session may go unreported before the launch is lost. */
153
+ export const SESSION_LOST_MS = 10 * 60_000
154
+
155
+ /** A provisioning run in this process; stop: a stop was asked for (its reason). */
156
+ interface Pipeline {
157
+ done: Promise<void>
158
+ stop?: string
159
+ /** Ends a wait in progress (for the harness's connection) when a stop comes. */
160
+ abort?: () => void
161
+ /** The engine stopped supervising (shutdown): the run stops at its next checkpoint, leaving the launch as it is. */
162
+ abandoned?: boolean
163
+ }
164
+
165
+ /** Thrown at a provisioning checkpoint after a stop. */
166
+ class StopRequested extends Error {}
167
+
168
+ /** Thrown at a provisioning checkpoint once the engine stopped supervising (the next leader recovers the launch). */
169
+ class Abandoned extends Error {}
170
+
171
+ /** The node is gone (or never came back): the launch is lost. */
172
+ class LostError extends Error {}
173
+
174
+ /** How long the engine waits for a started sandbox harness to connect back (its launch-bound connection). */
175
+ export const LAUNCH_CONNECT_MS = 120_000
176
+
177
+ /**
178
+ * How long the engine waits for a created session to be reported before it names and prompts it (cluster mode: the
179
+ * spoke may be on another pod, whose report reaches this one a moment after the create answer). It carries on after.
180
+ */
181
+ export const SESSION_REPORT_MS = 10_000
182
+
183
+ /** How long a restarted hub waits for a provisioning launch's node to (re)connect or let the launch go. */
184
+ export const NODE_RETURN_MS = 5 * 60_000
185
+
186
+ /** Node phases after which the node holds nothing for the launch. */
187
+ const RELEASED: ReadonlySet<NodeLaunchPhase> = new Set(["cancelled", "failed", "cleaned"])
188
+
189
+ /** Node phases in which the node runs the launch's harness (openshell): it is stopped before cleanup. */
190
+ const HARNESS_UP: ReadonlySet<NodeLaunchPhase> = new Set(["starting", "running"])
191
+
192
+ /**
193
+ * A workspace the node reports for a launch the hub has no record of. On the host it counts as reused, so cleanup
194
+ * never removes it; an openshell sandbox exists only for its launch, so it is the launch's own (removable).
195
+ */
196
+ function leftWorkspace(launch: Launch, report: Pick<NodeLaunchReport, "directory" | "sandbox">): PreparedWorkspace | undefined {
197
+ if (report.directory === undefined) return undefined
198
+ const { repo, mode, branch, base } = launch.spec.workspace
199
+ const { kind, profile } = launch.spec.runtime
200
+ const sandboxed = kind === "openshell"
201
+ return {
202
+ repo,
203
+ directory: report.directory,
204
+ mode,
205
+ ...(branch !== undefined ? { branch } : {}),
206
+ ...(base !== undefined ? { base } : {}),
207
+ reused: !sandboxed,
208
+ ...(sandboxed ? { runtime: { kind, ...(report.sandbox !== undefined ? { sandbox: report.sandbox } : {}), ...(profile !== undefined ? { profile } : {}) } } : {}),
209
+ }
210
+ }
211
+
212
+ /** A running launch being watched for its end. */
213
+ interface Watch {
214
+ readonly userID: string
215
+ readonly launchID: string
216
+ readonly sessionID: string
217
+ readonly when: LaunchSpec["end"]["when"]
218
+ seenWorking: boolean
219
+ /** The end it would take and since when that has held. */
220
+ candidate?: { readonly outcome: LaunchOutcome; readonly since: number }
221
+ unreportedSince?: number
222
+ settleTimer?: unknown
223
+ lostTimer?: unknown
224
+ timeoutTimer?: unknown
225
+ /** An evaluation runs; again: one more after it. */
226
+ evaluating?: boolean
227
+ again?: boolean
228
+ }
229
+
230
+ const defaultTimers: Timers = {
231
+ set: (fn, ms) => {
232
+ const handle = setTimeout(fn, ms)
233
+ ;(handle as { unref?: () => void }).unref?.()
234
+ return handle
235
+ },
236
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
237
+ }
238
+
239
+ /** A launch as the API shows it: the stored launch plus its session's directory. */
240
+ export type LaunchView = Launch & { readonly directory?: string }
241
+
242
+ /** `provider/model#variant` as a ModelSelection. */
243
+ export function modelSelection(model: string): ModelSelection {
244
+ const slash = model.indexOf("/")
245
+ const rest = model.slice(slash + 1)
246
+ const hash = rest.lastIndexOf("#")
247
+ return hash > 0
248
+ ? { providerID: model.slice(0, slash), modelID: rest.slice(0, hash), variant: rest.slice(hash + 1) }
249
+ : { providerID: model.slice(0, slash), modelID: rest }
250
+ }
251
+
252
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
253
+
254
+ /** The user's connected nodes as placement sees them. */
255
+ export function placementNodes(nodes: Pick<EngineNodes, "list">): { nodes(userID: string): PlacementNode[] } {
256
+ return {
257
+ nodes: (userID) =>
258
+ nodes.list(userID).map((node) => ({
259
+ machineID: node.machineID,
260
+ name: node.machineName,
261
+ owner: node.ownerID ?? "",
262
+ runtimes: node.runtimes,
263
+ labels: node.labels,
264
+ capacity: node.capacity,
265
+ used: node.used,
266
+ workspaceRoots: node.workspaceRoots,
267
+ draining: node.draining,
268
+ launchIDs: node.launches.map((l) => l.launchID),
269
+ features: node.features,
270
+ })),
271
+ }
272
+ }
273
+
274
+ export class LaunchEngine {
275
+ readonly store: LaunchStore
276
+ readonly placement: Placement
277
+ readonly loop: PlacementLoop
278
+ private readonly now: Clock
279
+ private readonly unsubscribe: (() => void)[] = []
280
+ /** Ends in progress, by launch ID. */
281
+ private readonly ending = new Map<string, Promise<Launch | undefined>>()
282
+ /** Provisioning runs in this process, by launch ID. */
283
+ private readonly pipelines = new Map<string, Pipeline>()
284
+ /** Running launches watched for their end, by launch ID. */
285
+ private readonly watches = new Map<string, Watch>()
286
+ /** Launches whose prompt was seen working before their watch was armed. */
287
+ private readonly seenEarly = new Set<string>()
288
+ private readonly timers: Timers
289
+ private tickTimer: unknown
290
+ /** start() ran and shutdown() did not: this engine runs placement, pipelines and end watchers. */
291
+ private supervising = false
292
+
293
+ constructor(private readonly deps: LaunchEngineDeps) {
294
+ this.now = deps.now ?? Date.now
295
+ this.timers = deps.timers ?? defaultTimers
296
+ this.store = new LaunchStore({ state: deps.state, now: this.now })
297
+ const { registry } = deps
298
+ this.placement = new Placement({
299
+ store: this.store,
300
+ nodes: placementNodes(deps.nodes),
301
+ spokes: {
302
+ launchHarnesses: (_userID, machineID) =>
303
+ registry.serviceHarnesses(machineID).filter((harness) => {
304
+ const connID = registry.serviceFor(machineID, harness)
305
+ return connID !== undefined && registry.supports(connID, "launch")
306
+ }),
307
+ },
308
+ counters: deps.counters,
309
+ locks: deps.locks,
310
+ owner: deps.owner,
311
+ ...(deps.caps ? { caps: deps.caps } : {}),
312
+ now: this.now,
313
+ })
314
+ this.loop = new PlacementLoop({
315
+ placement: this.placement,
316
+ users: async () => [...(await deps.users())],
317
+ onPlaced: (placed) => void this.provision(placed),
318
+ ...(deps.timers ? { timers: deps.timers } : {}),
319
+ })
320
+ }
321
+
322
+ /**
323
+ * Validates and queues a launch (or, same key and body, returns the existing one with created false). Throws
324
+ * invalid, name_taken (the name is held by a session or another live launch) or rate_limited (caps).
325
+ */
326
+ async accept(userID: string, body: unknown, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
327
+ const spec = validateLaunchSpec(body)
328
+ const launches = await this.store.list(userID)
329
+ const retry = spec.key !== undefined && launches.some((l) => l.spec.key === spec.key)
330
+ if (!retry) {
331
+ if (spec.name !== undefined) this.checkName(userID, spec.name, launches)
332
+ await this.placement.admit(userID)
333
+ }
334
+ const result = await this.store.create(userID, spec, options.by !== undefined ? { by: options.by } : {})
335
+ if (result.created) {
336
+ await this.placement.recordLaunch(userID)
337
+ this.changed(result.launch)
338
+ this.kick(userID)
339
+ }
340
+ return result
341
+ }
342
+
343
+ /** The launch with its directory; undefined for an unknown (or another user's) launch. */
344
+ async view(userID: string, id: string): Promise<LaunchView | undefined> {
345
+ const launch = await this.store.get(userID, id)
346
+ if (!launch) return undefined
347
+ const directory = (await this.work(userID, id))?.workspace?.directory
348
+ return directory !== undefined ? { ...launch, directory } : launch
349
+ }
350
+
351
+ /** The user's launches by creation (filtered), with their directories. */
352
+ async list(userID: string, filter: LaunchFilter = {}): Promise<LaunchView[]> {
353
+ const launches = await this.store.list(userID, filter)
354
+ return Promise.all(
355
+ launches.map(async (launch) => {
356
+ const directory = (await this.work(userID, launch.id))?.workspace?.directory
357
+ return directory !== undefined ? { ...launch, directory } : launch
358
+ }),
359
+ )
360
+ }
361
+
362
+ /** The labels of the (latest) launch that started the session, or undefined when no launch did. */
363
+ async labelsFor(userID: string, sessionID: string): Promise<Readonly<Record<string, string>> | undefined> {
364
+ const launches = await this.store.list(userID)
365
+ return launches.reverse().find((l) => l.sessionID === sessionID)?.spec.labels
366
+ }
367
+
368
+ /**
369
+ * Starts the engine: re-arms running launches, recovers provisioning ones, starts placement (sweep, and kicks
370
+ * on node and spoke changes) and reconciles nodes that connect.
371
+ */
372
+ async start(): Promise<void> {
373
+ if (this.supervising) return
374
+ this.supervising = true
375
+ const { nodes, registry } = this.deps
376
+ this.unsubscribe.push(
377
+ nodes.subscribe((event) => {
378
+ if (event.type === "disconnected" || event.node.ownerID === undefined) return
379
+ if (event.type === "connected") void this.reconcile(event.node)
380
+ void this.loop.kick(event.node.ownerID)
381
+ }, REMOTE),
382
+ registry.subscribe((message) => {
383
+ if (message.type === "status") {
384
+ const ids = new Set([message.sessionID, ...message.sessions.map((s) => s.id)])
385
+ for (const watch of this.watches.values()) if (ids.has(watch.sessionID)) this.evaluate(watch)
386
+ } else if (message.type === "catalog") for (const watch of this.watches.values()) this.evaluate(watch)
387
+ }),
388
+ registry.onSessions((connID) => {
389
+ const userID = registry.connTenant(connID)
390
+ if (userID !== undefined) void this.loop.kick(userID)
391
+ }),
392
+ )
393
+ this.loop.start()
394
+ this.scheduleTick()
395
+ for (const userID of await this.deps.users()) {
396
+ try {
397
+ for (const launch of await this.store.list(userID, { states: ["provisioning", "running"] })) {
398
+ if (launch.state === "running") this.arm(userID, launch, (await this.work(userID, launch.id))?.seenWorking === true)
399
+ else void this.recover(userID, launch)
400
+ }
401
+ } catch (cause) {
402
+ console.error(`[hub] launch recovery for ${userID} failed: ${errorText(cause)}`)
403
+ }
404
+ void this.loop.kick(userID)
405
+ }
406
+ }
407
+
408
+ /** Stops supervising: placement, end watchers and listeners stop; provisioning runs stop at their next step. */
409
+ shutdown(): void {
410
+ this.supervising = false
411
+ for (const pipeline of this.pipelines.values()) {
412
+ pipeline.abandoned = true
413
+ pipeline.abort?.()
414
+ }
415
+ this.loop.stop()
416
+ if (this.tickTimer !== undefined) this.timers.clear(this.tickTimer)
417
+ this.tickTimer = undefined
418
+ for (const id of [...this.watches.keys()]) this.disarm(id)
419
+ for (const off of this.unsubscribe.splice(0)) off()
420
+ }
421
+
422
+ // ---- provisioning ------------------------------------------------------------------------------------
423
+
424
+ /**
425
+ * Stops a launch (DELETE): a queued one ends at once; a provisioning one is cancelled on the node and ends before
426
+ * its session is created or prompted; a running one has its session stopped, then artifacts and cleanup. Throws
427
+ * not_found (unknown, or another user's) or gone (already ended).
428
+ */
429
+ async stop(userID: string, id: string, reason = "Stopped"): Promise<Launch> {
430
+ for (;;) {
431
+ const launch = await this.store.get(userID, id)
432
+ if (!launch) throw new AgentError("not_found", `No launch ${id}`)
433
+ if (!this.supervising && this.deps.supervisor && (launch.state === "provisioning" || launch.state === "running")) {
434
+ const stopped = await this.deps.supervisor.stop(userID, id, reason)
435
+ if (stopped) return stopped
436
+ }
437
+ switch (launch.state) {
438
+ case "ended":
439
+ throw new AgentError("gone", "The launch already ended")
440
+ case "queued":
441
+ case "placing":
442
+ try {
443
+ const ended = await this.store.transition(userID, id, "ended", { reason, outcome: "stopped", from: ["queued", "placing"] })
444
+ this.changed(ended)
445
+ return ended
446
+ } catch (cause) {
447
+ // Placement moved it on meanwhile: stop it where it is now.
448
+ if (cause instanceof LaunchTransitionError && cause.code !== "gone") continue
449
+ throw cause
450
+ }
451
+ case "provisioning": {
452
+ const pipeline = this.pipelines.get(id)
453
+ if (pipeline) {
454
+ pipeline.stop = reason
455
+ const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
456
+ const cancelled = node ? this.deps.nodeOps.requestNode(node.connID, { op: "cancel", launchID: id }).catch(() => undefined) : undefined
457
+ pipeline.abort?.()
458
+ await cancelled
459
+ await pipeline.done
460
+ return (await this.store.get(userID, id))!
461
+ }
462
+ return (await this.end(userID, id, "stopped", reason))!
463
+ }
464
+ case "running":
465
+ return (await this.end(userID, id, "stopped", reason, { stopSession: true }))!
466
+ }
467
+ }
468
+ }
469
+
470
+ /** A placement pass for the user: here when this engine supervises, else on the supervisor (leader). */
471
+ kick(userID: string): void {
472
+ if (!this.supervising && this.deps.supervisor) this.deps.supervisor.kick(userID)
473
+ else void this.loop.kick(userID)
474
+ }
475
+
476
+ /** A placed launch: prepare → create → roles, name, primer → prompt → running. */
477
+ private provision(placed: Placed): Promise<void> {
478
+ const { userID, launch } = placed
479
+ return this.pipeline(userID, launch, async (checkpoint) => {
480
+ this.changed(launch)
481
+ await this.putWork(userID, launch.id, { fence: placed.fence })
482
+ const workspace = await this.prepare(userID, launch)
483
+ checkpoint()
484
+ const connID = await this.harnessConn(userID, launch, workspace)
485
+ checkpoint()
486
+ const sessionID = await this.create(userID, launch, workspace, connID)
487
+ checkpoint()
488
+ await this.identify(userID, launch, sessionID, checkpoint)
489
+ })
490
+ }
491
+
492
+ /**
493
+ * A launch a previous run of the hub left provisioning carries on from its work record: prompted → running;
494
+ * a session → identity and prompt; a workspace → the launch create; nothing → whatever the node still holds
495
+ * for it is released (a running prepare cancelled, a prepared workspace kept as reused), then prepare again.
496
+ * A node that does not come back within NODE_RETURN_MS loses the launch.
497
+ */
498
+ private recover(userID: string, launch: Launch): Promise<void> {
499
+ return this.pipeline(userID, launch, async (checkpoint) => {
500
+ const work = (await this.work(userID, launch.id)) ?? {}
501
+ if (work.sessionID !== undefined) {
502
+ if (work.prompted) await this.toRunning(userID, launch, work.sessionID, [], work.seenWorking === true)
503
+ else await this.identify(userID, launch, work.sessionID, checkpoint)
504
+ return
505
+ }
506
+ let workspace = work.workspace
507
+ if (!workspace) {
508
+ const node = await this.waitForNode(launch.machineID!)
509
+ await this.releaseOnNode(node, launch)
510
+ checkpoint()
511
+ workspace = await this.prepare(userID, launch)
512
+ checkpoint()
513
+ }
514
+ const connID = await this.harnessConn(userID, launch, workspace, work.workspace !== undefined && work.started === true)
515
+ checkpoint()
516
+ const sessionID = await this.create(userID, launch, workspace, connID)
517
+ checkpoint()
518
+ await this.identify(userID, launch, sessionID, checkpoint)
519
+ })
520
+ }
521
+
522
+ /** Runs provisioning steps once per launch in this process; a failure ends the launch (stopped, lost or failed). */
523
+ private pipeline(userID: string, launch: Launch, steps: (checkpoint: () => void) => Promise<void>): Promise<void> {
524
+ const running = this.pipelines.get(launch.id)
525
+ if (running) return running.done
526
+ const pipeline: Pipeline = { done: Promise.resolve() }
527
+ this.pipelines.set(launch.id, pipeline)
528
+ const checkpoint = () => {
529
+ if (pipeline.abandoned) throw new Abandoned()
530
+ if (pipeline.stop !== undefined) throw new StopRequested()
531
+ }
532
+ pipeline.done = (async () => {
533
+ try {
534
+ await steps(checkpoint)
535
+ } catch (cause) {
536
+ if (pipeline.abandoned) return
537
+ if (pipeline.stop !== undefined) {
538
+ await this.end(userID, launch.id, "stopped", pipeline.stop)
539
+ return
540
+ }
541
+ const gone = cause instanceof LostError || this.deps.nodes.forMachine(launch.machineID!) === undefined
542
+ const reason = cause instanceof LostError ? cause.message : gone ? "The node went away while preparing the launch" : `Failed: ${errorText(cause)}`
543
+ await this.end(userID, launch.id, gone ? "lost" : "failed", reason)
544
+ }
545
+ })()
546
+ .catch((cause) => console.error(`[hub] launch ${launch.id}: ${errorText(cause)}`))
547
+ .finally(() => this.pipelines.delete(launch.id))
548
+ return pipeline.done
549
+ }
550
+
551
+ /** The machine's node, now or once it connects (LostError after NODE_RETURN_MS). */
552
+ private waitForNode(machineID: string): Promise<NodeView> {
553
+ const { nodes } = this.deps
554
+ const now = nodes.forMachine(machineID)
555
+ if (now) return Promise.resolve(now)
556
+ return new Promise((resolve, reject) => {
557
+ const off = nodes.subscribe((event) => {
558
+ if (event.type !== "connected" || event.node.machineID !== machineID) return
559
+ done()
560
+ resolve(event.node)
561
+ }, REMOTE)
562
+ const timer = this.timers.set(() => {
563
+ done()
564
+ reject(new LostError(`The node did not come back within ${NODE_RETURN_MS / 60_000} minutes`))
565
+ }, NODE_RETURN_MS)
566
+ const done = () => {
567
+ off()
568
+ this.timers.clear(timer)
569
+ }
570
+ })
571
+ }
572
+
573
+ /**
574
+ * Releases what the node still holds for a launch the hub lost track of: cancels a prepare, keeps a host
575
+ * workspace (as reused), stops and removes an openshell sandbox (the launch's own, nothing collected yet).
576
+ */
577
+ private async releaseOnNode(node: NodeView, launch: Launch): Promise<void> {
578
+ const report = node.launches.find((l) => l.launchID === launch.id)
579
+ if (!report) return
580
+ const { nodeOps } = this.deps
581
+ if (report.phase === "preparing") {
582
+ const released = this.waitForRelease(node.machineID, launch.id)
583
+ await nodeOps.requestNode(node.connID, { op: "cancel", launchID: launch.id })
584
+ await released
585
+ return
586
+ }
587
+ const workspace = leftWorkspace(launch, report)
588
+ if (!workspace) return
589
+ if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: launch.id, workspace, mode: "kill" })
590
+ await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: launch.id, workspace, policy: workspace.reused ? "keep" : "remove" })
591
+ }
592
+
593
+ /** Resolves when the node lets the launch go (cancelled, failed or cleaned); LostError when the node goes. */
594
+ private waitForRelease(machineID: string, launchID: string): Promise<void> {
595
+ return new Promise((resolve, reject) => {
596
+ const off = this.deps.nodes.subscribe((event) => {
597
+ if (event.node.machineID !== machineID) return
598
+ if (event.type === "progress" && event.launchID === launchID && RELEASED.has(event.phase)) {
599
+ done()
600
+ resolve()
601
+ } else if (event.type === "disconnected") {
602
+ done()
603
+ reject(new LostError("The node went away"))
604
+ }
605
+ }, REMOTE)
606
+ const timer = this.timers.set(() => {
607
+ done()
608
+ reject(new LostError(`The node did not let the launch go within ${NODE_RETURN_MS / 60_000} minutes`))
609
+ }, NODE_RETURN_MS)
610
+ const done = () => {
611
+ off()
612
+ this.timers.clear(timer)
613
+ }
614
+ })
615
+ }
616
+
617
+ /** A node (re)connected: it cleans up what it still holds for launches that ended (or that this hub forgot). */
618
+ private async reconcile(node: NodeView): Promise<void> {
619
+ const userID = node.ownerID
620
+ if (userID === undefined) return
621
+ const { nodeOps } = this.deps
622
+ for (const report of node.launches) {
623
+ if (this.pipelines.has(report.launchID)) continue
624
+ const launch = await this.store.get(userID, report.launchID)
625
+ if (launch && launch.state !== "ended") continue
626
+ try {
627
+ if (report.phase === "preparing") {
628
+ await nodeOps.requestNode(node.connID, { op: "cancel", launchID: report.launchID })
629
+ continue
630
+ }
631
+ const work = await this.work(userID, report.launchID)
632
+ const workspace = work?.workspace ?? (launch ? leftWorkspace(launch, report) : undefined)
633
+ if (!workspace) continue
634
+ if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
635
+ await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
636
+ } catch (cause) {
637
+ console.error(`[hub] launch ${report.launchID}: cleanup on the node failed: ${errorText(cause)}`)
638
+ }
639
+ }
640
+ }
641
+
642
+ private async prepare(userID: string, launch: Launch): Promise<PreparedWorkspace> {
643
+ const node = this.deps.nodes.forMachine(launch.machineID!)
644
+ if (!node) throw new Error("The node is not connected")
645
+ const { spec } = launch
646
+ const { workspace } = await this.deps.nodeOps.requestNode(node.connID, {
647
+ op: "prepare",
648
+ launchID: launch.id,
649
+ runtime: spec.runtime,
650
+ harness: spec.harness,
651
+ workspace: spec.workspace,
652
+ })
653
+ await this.updateWork(userID, launch.id, { workspace })
654
+ return workspace
655
+ }
656
+
657
+ /**
658
+ * The connection that creates the launch's session: the machine's service spoke (host), or for openshell the
659
+ * launch-bound connection of the harness the node starts in its sandbox.
660
+ */
661
+ private async harnessConn(userID: string, launch: Launch, workspace: PreparedWorkspace, started = false): Promise<string> {
662
+ const { registry } = this.deps
663
+ if (launch.spec.runtime.kind === "openshell") {
664
+ // A harness started before a hub restart may still be connected; else it is (re)started (a node already
665
+ // running it answers started false, and the harness reconnects with its own token).
666
+ const connected = started ? registry.launchConn(launch.id) : undefined
667
+ return connected ?? this.startHarness(userID, launch, workspace)
668
+ }
669
+ const { harness } = launch.spec
670
+ const connID = registry.serviceFor(launch.machineID!, harness)
671
+ if (connID === undefined || !registry.supports(connID, "launch")) throw new Error(`The machine has no connected ${harness} service spoke with launch`)
672
+ return connID
673
+ }
674
+
675
+ /** Mints the launch's token, has the node start the harness with it and waits for its launch-bound connection. */
676
+ private async startHarness(userID: string, launch: Launch, workspace: PreparedWorkspace): Promise<string> {
677
+ const { launchTokens, hubURL, nodeOps, nodes } = this.deps
678
+ if (!launchTokens || hubURL === undefined) throw new Error("This hub cannot start sandboxed launches")
679
+ const node = nodes.forMachine(launch.machineID!) ?? (await this.waitForNode(launch.machineID!))
680
+ const minted = await launchTokens.mint(launch.machineID!, launch.id)
681
+ if (!minted) throw new Error("The hub could not mint the launch token")
682
+ await this.updateWork(userID, launch.id, { started: true })
683
+ await nodeOps.requestNode(node.connID, { op: "start", launchID: launch.id, workspace, harness: launch.spec.harness, hub: { url: hubURL, token: minted.token } })
684
+ return this.waitForLaunchConn(launch.id)
685
+ }
686
+
687
+ /**
688
+ * The launch's launch-bound connection, now or once it connects; fails after LAUNCH_CONNECT_MS, and at once
689
+ * (StopRequested) when the launch is stopped meanwhile.
690
+ */
691
+ private waitForLaunchConn(launchID: string): Promise<string> {
692
+ const { registry } = this.deps
693
+ const now = registry.launchConn(launchID)
694
+ if (now !== undefined) return Promise.resolve(now)
695
+ const pipeline = this.pipelines.get(launchID)
696
+ if (pipeline?.stop !== undefined) return Promise.reject(new StopRequested())
697
+ return new Promise((resolve, reject) => {
698
+ const off = registry.subscribe((message) => {
699
+ if (message.type !== "catalog") return
700
+ const connID = registry.launchConn(launchID)
701
+ if (connID === undefined) return
702
+ done()
703
+ resolve(connID)
704
+ })
705
+ const timer = this.timers.set(() => {
706
+ done()
707
+ reject(new Error(`The sandbox's harness did not connect within ${LAUNCH_CONNECT_MS / 1000} s`))
708
+ }, LAUNCH_CONNECT_MS)
709
+ if (pipeline) {
710
+ pipeline.abort = () => {
711
+ done()
712
+ reject(new StopRequested())
713
+ }
714
+ }
715
+ const done = () => {
716
+ off()
717
+ this.timers.clear(timer)
718
+ if (pipeline) pipeline.abort = undefined
719
+ }
720
+ })
721
+ }
722
+
723
+ /** Resolves once some connection reports the session, or after SESSION_REPORT_MS (or a stop) all the same. */
724
+ private waitForSession(sessionID: string): Promise<void> {
725
+ const { registry } = this.deps
726
+ if (registry.hasSession(sessionID)) return Promise.resolve()
727
+ return new Promise((resolve) => {
728
+ const off = registry.subscribe(() => {
729
+ if (registry.hasSession(sessionID)) done()
730
+ })
731
+ const timer = this.timers.set(done, SESSION_REPORT_MS)
732
+ function done() {
733
+ off()
734
+ clear()
735
+ resolve()
736
+ }
737
+ const clear = () => this.timers.clear(timer)
738
+ })
739
+ }
740
+
741
+ private async create(userID: string, launch: Launch, workspace: PreparedWorkspace, connID: string): Promise<string> {
742
+ const { registry, spokes } = this.deps
743
+ if (!registry.supports(connID, "launch")) throw new Error("The launch's spoke does not create launch sessions")
744
+ const { spec } = launch
745
+ const result = (await spokes.request(connID, {
746
+ op: "create",
747
+ directory: workspace.directory,
748
+ ...(spec.name !== undefined ? { title: spec.name } : {}),
749
+ launchID: launch.id,
750
+ ...(spec.agent !== undefined ? { agent: spec.agent } : {}),
751
+ ...(spec.model !== undefined ? { model: modelSelection(spec.model) } : {}),
752
+ })) as { sessionID: string }
753
+ await this.updateWork(userID, launch.id, { sessionID: result.sessionID })
754
+ return result.sessionID
755
+ }
756
+
757
+ /** Roles, the name and the primer, then the prompt; the launch is running. */
758
+ private async identify(userID: string, launch: Launch, sessionID: string, checkpoint: () => void = () => {}): Promise<void> {
759
+ const { directory, delivery, actions } = this.deps
760
+ const { spec } = launch
761
+ await this.waitForSession(sessionID)
762
+ checkpoint()
763
+ await directory.sync(userID)
764
+ const notes: string[] = []
765
+ const byUser = launch.by === undefined || launch.by === "user"
766
+ for (const role of spec.roles) {
767
+ try {
768
+ await directory.claimRole(userID, sessionID, role, byUser ? { byUser: true } : {})
769
+ } catch (cause) {
770
+ notes.push(`@@${role} not claimed: ${errorText(cause)}`)
771
+ }
772
+ }
773
+ if (spec.name !== undefined) await directory.setName(userID, sessionID, spec.name)
774
+ await delivery.prime(userID, sessionID)
775
+ checkpoint()
776
+ if (spec.prompt !== undefined) {
777
+ await this.updateWork(userID, launch.id, { prompted: true })
778
+ const prompted = await actions.prompt({ sessionID, text: spec.prompt, resetHops: false })
779
+ if (prompted.outcome === "offline") throw new Error("The session went offline before its prompt")
780
+ if (prompted.outcome === "unsupported") throw new Error(unsupportedMessage(prompted))
781
+ if (prompted.mode === "steer") this.sawWorking(userID, launch.id)
782
+ else void prompted.result.then(
783
+ () => this.sawWorking(userID, launch.id),
784
+ () => this.sawWorking(userID, launch.id),
785
+ )
786
+ }
787
+ await this.toRunning(userID, launch, sessionID, notes, false)
788
+ }
789
+
790
+ private async toRunning(userID: string, launch: Launch, sessionID: string, notes: readonly string[], seenWorking: boolean): Promise<void> {
791
+ const { name } = launch.spec
792
+ const running = await this.store.transition(userID, launch.id, "running", {
793
+ reason: ["Running", ...notes].join("; "),
794
+ sessionID,
795
+ ...(name !== undefined ? { name } : {}),
796
+ from: ["provisioning"],
797
+ })
798
+ this.changed(running)
799
+ this.arm(userID, running, seenWorking)
800
+ }
801
+
802
+ // ---- the end watcher ----------------------------------------------------------------------------------
803
+
804
+ /** Watches a running launch for its end (timeout, idle or asked, a lost session). */
805
+ private arm(userID: string, launch: Launch, seenWorking: boolean): void {
806
+ if (this.watches.has(launch.id) || launch.sessionID === undefined) return
807
+ const watch: Watch = { userID, launchID: launch.id, sessionID: launch.sessionID, when: launch.spec.end.when, seenWorking: seenWorking || this.seenEarly.delete(launch.id) }
808
+ this.watches.set(launch.id, watch)
809
+ const { timeoutMs } = launch.spec.end
810
+ if (timeoutMs !== undefined) {
811
+ watch.timeoutTimer = this.timers.set(
812
+ () => void this.end(userID, launch.id, "timeout", "Timed out", { stopSession: true }),
813
+ Math.max(0, launch.at + timeoutMs - this.now()),
814
+ )
815
+ }
816
+ this.evaluate(watch)
817
+ }
818
+
819
+ private disarm(id: string): void {
820
+ const watch = this.watches.get(id)
821
+ if (!watch) return
822
+ this.watches.delete(id)
823
+ for (const timer of [watch.settleTimer, watch.lostTimer, watch.timeoutTimer]) if (timer !== undefined) this.timers.clear(timer)
824
+ }
825
+
826
+ /** The launch's prompt was seen working: an idle end may start from now on. */
827
+ private sawWorking(userID: string, id: string): void {
828
+ const watch = this.watches.get(id)
829
+ if (!watch) {
830
+ this.seenEarly.add(id)
831
+ return
832
+ }
833
+ if (!watch.seenWorking) {
834
+ watch.seenWorking = true
835
+ void this.updateWork(userID, id, { seenWorking: true }).catch((cause) => console.error(`[hub] launch ${id}: ${errorText(cause)}`))
836
+ }
837
+ this.evaluate(watch)
838
+ }
839
+
840
+ private scheduleTick(): void {
841
+ this.tickTimer = this.timers.set(() => {
842
+ for (const watch of this.watches.values()) this.evaluate(watch)
843
+ this.scheduleTick()
844
+ }, END_TICK_MS)
845
+ }
846
+
847
+ /** Looks at the watched launch now (one evaluation at a time per launch; a call during one makes one more). */
848
+ private evaluate(watch: Watch): void {
849
+ if (watch.evaluating) {
850
+ watch.again = true
851
+ return
852
+ }
853
+ watch.evaluating = true
854
+ void (async () => {
855
+ try {
856
+ do {
857
+ watch.again = false
858
+ await this.evaluateNow(watch)
859
+ } while (watch.again && this.watches.get(watch.launchID) === watch)
860
+ } catch (cause) {
861
+ console.error(`[hub] launch ${watch.launchID}: end check failed: ${errorText(cause)}`)
862
+ } finally {
863
+ watch.evaluating = false
864
+ }
865
+ })()
866
+ }
867
+
868
+ private async evaluateNow(watch: Watch): Promise<void> {
869
+ if (this.watches.get(watch.launchID) !== watch || this.ending.has(watch.launchID)) return
870
+ const { registry, delivery, decisions } = this.deps
871
+ const { userID, sessionID } = watch
872
+ const now = this.now()
873
+ if (!registry.hasSession(sessionID)) {
874
+ this.clearCandidate(watch)
875
+ watch.unreportedSince ??= now
876
+ if (now - watch.unreportedSince >= SESSION_LOST_MS) {
877
+ await this.end(userID, watch.launchID, "lost", `The session was not reported for ${SESSION_LOST_MS / 60_000} minutes`)
878
+ } else if (watch.lostTimer === undefined) {
879
+ watch.lostTimer = this.timers.set(() => {
880
+ watch.lostTimer = undefined
881
+ this.evaluate(watch)
882
+ }, watch.unreportedSince + SESSION_LOST_MS - now)
883
+ }
884
+ return
885
+ }
886
+ watch.unreportedSince = undefined
887
+ if (watch.lostTimer !== undefined) this.timers.clear(watch.lostTimer)
888
+ watch.lostTimer = undefined
889
+ const view = registry.session(sessionID)
890
+ if (view?.state === "working") {
891
+ this.clearCandidate(watch)
892
+ if (!watch.seenWorking) this.sawWorking(userID, watch.launchID)
893
+ return
894
+ }
895
+ if (!watch.seenWorking || watch.when === "never") return
896
+ const party = { kind: "session", sessionID } as const
897
+ const asks = (await delivery.messages.inbox(userID, party)).waiting.length
898
+ const open = (view?.blockers.length ?? 0) + asks + (await decisions.list(userID, { state: "open", from: party })).length > 0
899
+ const idle = registry.idle(sessionID)
900
+ const outcome: LaunchOutcome | undefined =
901
+ idle && !open ? "idle" : watch.when === "asked" && open && (idle || view?.state === "blocked") ? "needs_user" : undefined
902
+ if (outcome === undefined) return this.clearCandidate(watch)
903
+ // Times are when the state was read (now), not after the awaits.
904
+ if (watch.candidate?.outcome !== outcome) {
905
+ this.clearCandidate(watch)
906
+ watch.candidate = { outcome, since: now }
907
+ watch.settleTimer = this.timers.set(() => {
908
+ watch.settleTimer = undefined
909
+ this.evaluate(watch)
910
+ }, Math.max(0, now + END_SETTLE_MS - this.now()))
911
+ return
912
+ }
913
+ if (now - watch.candidate.since < END_SETTLE_MS) return
914
+ await this.end(userID, watch.launchID, outcome, outcome === "idle" ? "Idle: the session finished" : "Needs the user: the session asked a question")
915
+ }
916
+
917
+ private clearCandidate(watch: Watch): void {
918
+ watch.candidate = undefined
919
+ if (watch.settleTimer !== undefined) this.timers.clear(watch.settleTimer)
920
+ watch.settleTimer = undefined
921
+ }
922
+
923
+ // ---- the end ---------------------------------------------------------------------------------------------
924
+
925
+ /**
926
+ * Ends a live launch once (concurrent calls share the first): stops its session when asked, collects artifacts
927
+ * and cleans the workspace up on the node, then records the ended step. Resolves with the launch as it is then.
928
+ */
929
+ private end(userID: string, id: string, outcome: LaunchOutcome, reason: string, options: { readonly stopSession?: boolean } = {}): Promise<Launch | undefined> {
930
+ const running = this.ending.get(id)
931
+ if (running) return running
932
+ const run = this.endNow(userID, id, outcome, reason, options).finally(() => this.ending.delete(id))
933
+ this.ending.set(id, run)
934
+ return run
935
+ }
936
+
937
+ private async endNow(userID: string, id: string, outcome: LaunchOutcome, reason: string, options: { readonly stopSession?: boolean }): Promise<Launch | undefined> {
938
+ const launch = await this.store.get(userID, id)
939
+ if (!launch || launch.state === "ended") return launch
940
+ const work = await this.work(userID, id)
941
+ if (options.stopSession && work?.sessionID) {
942
+ await this.deps.actions.stop(work.sessionID).catch((cause) => console.error(`[hub] launch ${id}: stopping its session failed: ${errorText(cause)}`))
943
+ }
944
+ const notes: string[] = []
945
+ let artifacts: Record<string, unknown> | undefined
946
+ if (work?.workspace) {
947
+ const policy: CleanupPolicy = outcome === "needs_user" ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
948
+ const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
949
+ if (node) {
950
+ if (work.started) {
951
+ const mode = outcome === "idle" || outcome === "needs_user" ? "graceful" : "kill"
952
+ try {
953
+ await this.deps.nodeOps.requestNode(node.connID, { op: "stop", launchID: id, workspace: work.workspace, mode })
954
+ } catch (cause) {
955
+ notes.push(`harness not stopped: ${errorText(cause)}`)
956
+ }
957
+ }
958
+ if (work.prompted) {
959
+ try {
960
+ artifacts = { ...(await this.deps.nodeOps.requestNode(node.connID, { op: "collect", launchID: id, workspace: work.workspace })).artifacts }
961
+ } catch (cause) {
962
+ notes.push(`artifacts not collected: ${errorText(cause)}`)
963
+ }
964
+ }
965
+ try {
966
+ const cleaned = await this.deps.nodeOps.requestNode(node.connID, { op: "cleanup", launchID: id, workspace: work.workspace, policy })
967
+ notes.push(cleaned.removed ? "workspace removed" : `workspace kept${cleaned.reason ? ` (${cleaned.reason})` : ""}`)
968
+ } catch (cause) {
969
+ notes.push(`workspace cleanup failed: ${errorText(cause)}`)
970
+ }
971
+ } else {
972
+ notes.push("workspace cleanup waits for the node")
973
+ await this.updateWork(userID, id, { cleanup: policy })
974
+ }
975
+ }
976
+ try {
977
+ const ended = await this.store.transition(userID, id, "ended", {
978
+ reason: [reason, ...notes].join("; "),
979
+ outcome,
980
+ ...(artifacts ? { artifacts } : {}),
981
+ from: LIVE,
982
+ })
983
+ this.disarm(id)
984
+ this.changed(ended)
985
+ this.kick(userID)
986
+ return ended
987
+ } catch (cause) {
988
+ if (cause instanceof LaunchTransitionError) return this.store.get(userID, id)
989
+ throw cause
990
+ }
991
+ }
992
+
993
+ // ---- work records -------------------------------------------------------------------------------------
994
+
995
+ private async work(userID: string, id: string): Promise<LaunchWork | undefined> {
996
+ return (await this.deps.state.get<LaunchWork>(userID, LAUNCH_WORK, id))?.value
997
+ }
998
+
999
+ private async putWork(userID: string, id: string, work: LaunchWork): Promise<void> {
1000
+ await this.deps.state.put(userID, LAUNCH_WORK, id, work)
1001
+ }
1002
+
1003
+ private async updateWork(userID: string, id: string, change: Partial<LaunchWork>): Promise<void> {
1004
+ await this.putWork(userID, id, { ...(await this.work(userID, id)), ...change })
1005
+ }
1006
+
1007
+ private checkName(userID: string, name: string, launches: readonly Launch[]): void {
1008
+ if (launches.some((l) => LIVE.includes(l.state) && l.spec.name === name)) throw new AgentError("name_taken", `@${name} is taken by another launch`)
1009
+ try {
1010
+ this.deps.directory.resolve(userID, `@${name}`)
1011
+ } catch (cause) {
1012
+ if (cause instanceof AgentError && cause.code === "not_found") return
1013
+ throw cause
1014
+ }
1015
+ throw new AgentError("name_taken", `@${name} is taken`)
1016
+ }
1017
+
1018
+ private changed(launch: Launch): void {
1019
+ try {
1020
+ this.deps.onLaunchChanged?.(launch)
1021
+ } catch (cause) {
1022
+ console.error("[hub] launch listener failed:", cause instanceof Error ? cause.message : cause)
1023
+ }
1024
+ }
1025
+ }