@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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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
|
+
}
|