@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,281 @@
|
|
|
1
|
+
// The node daemon (design §6.3): one NodeConnection to the hub and the runtimes it offers. It serves the hub's
|
|
2
|
+
// prepare → (start → stop) → collect → cleanup (and cancel) for each launch, reports progress frames, and holds a
|
|
3
|
+
// slot per launch from prepare until cleanup (or a failed or cancelled prepare), sending a capacity frame whenever
|
|
4
|
+
// use changes. prepare goes to the runtime the request names; every later op to the runtime the prepared workspace
|
|
5
|
+
// names (workspace.runtime, absent = host). It announces the "sandbox" feature (start and stop) only when one of its
|
|
6
|
+
// runtimes starts harnesses (openshell); the host runtime answers start and stop with started/stopped false.
|
|
7
|
+
// It keeps launches in memory only: the later ops carry the prepared workspace, so a restarted node can still
|
|
8
|
+
// serve them. There is no remote exec channel: these ops are all the hub can ask.
|
|
9
|
+
import { randomUUID } from "node:crypto"
|
|
10
|
+
import { arch as osArch, platform as osPlatform } from "node:os"
|
|
11
|
+
import {
|
|
12
|
+
NODE_FEATURES,
|
|
13
|
+
NODE_MESSAGE_MAX,
|
|
14
|
+
preparedWorkspaceRuntime,
|
|
15
|
+
type NodeCapacity,
|
|
16
|
+
type NodeHello,
|
|
17
|
+
type NodeLaunchPhase,
|
|
18
|
+
type NodeLaunchReport,
|
|
19
|
+
type NodeRequest,
|
|
20
|
+
type NodeResultFor,
|
|
21
|
+
type NodeRuntimeInfo,
|
|
22
|
+
type PreparedWorkspace,
|
|
23
|
+
} from "../../protocol/src/index.ts"
|
|
24
|
+
import { RuntimeError, type Runtime } from "../../runtime-sdk/src/index.ts"
|
|
25
|
+
import pkg from "../package.json" with { type: "json" }
|
|
26
|
+
import type { NodeConfig, SpokeCredentials } from "./config.ts"
|
|
27
|
+
import { NodeConnection, type NodeConnectionOptions, type NodeConnectionState } from "./connection.ts"
|
|
28
|
+
import { HostRuntime } from "./host.ts"
|
|
29
|
+
|
|
30
|
+
export const NODE_VERSION: string = pkg.version
|
|
31
|
+
|
|
32
|
+
/** What the daemon needs of a connection (NodeConnection, or a fake in tests). */
|
|
33
|
+
export interface ConnectionLike {
|
|
34
|
+
start(): void
|
|
35
|
+
stop(): void
|
|
36
|
+
send(frame: Parameters<NodeConnection["send"]>[0]): boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NodeDaemonOptions {
|
|
40
|
+
readonly config: NodeConfig
|
|
41
|
+
readonly credentials: SpokeCredentials
|
|
42
|
+
/** Default: the host runtime over the config's roots. */
|
|
43
|
+
readonly runtimes?: readonly Runtime[]
|
|
44
|
+
readonly instanceID?: string
|
|
45
|
+
/** The os and arch labels (default this machine's). */
|
|
46
|
+
readonly platform?: { readonly os: string; readonly arch: string }
|
|
47
|
+
readonly connect?: (options: NodeConnectionOptions) => ConnectionLike
|
|
48
|
+
readonly onState?: (state: NodeConnectionState, error?: string) => void
|
|
49
|
+
/** Backends per runtime kind (e.g. openshell: the gateway's compute drivers), added to the hello's runtimes. */
|
|
50
|
+
readonly backends?: Readonly<Record<string, readonly string[]>>
|
|
51
|
+
/** Launches found at startup (an earlier run's sandboxes): reported in the hello, each holding a slot until cleanup. */
|
|
52
|
+
readonly recovered?: readonly NodeLaunchReport[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Tracked {
|
|
56
|
+
phase: NodeLaunchPhase
|
|
57
|
+
directory?: string
|
|
58
|
+
sandbox?: string
|
|
59
|
+
abort?: AbortController
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A message fit for a progress frame: one line of printable text, at most NODE_MESSAGE_MAX characters. */
|
|
63
|
+
export function progressText(text: string): string | undefined {
|
|
64
|
+
const line = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]+/gu, " ").replace(/\s+/g, " ").trim()
|
|
65
|
+
return line ? line.slice(0, NODE_MESSAGE_MAX) : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
69
|
+
|
|
70
|
+
export class NodeDaemon {
|
|
71
|
+
private readonly launches = new Map<string, Tracked>()
|
|
72
|
+
private readonly runtimes: readonly Runtime[]
|
|
73
|
+
private readonly instanceID: string
|
|
74
|
+
private readonly labels: Readonly<Record<string, string>>
|
|
75
|
+
private draining = false
|
|
76
|
+
private connection?: ConnectionLike
|
|
77
|
+
|
|
78
|
+
constructor(private readonly options: NodeDaemonOptions) {
|
|
79
|
+
this.runtimes = options.runtimes ?? [new HostRuntime({ roots: options.config.workspaceRoots })]
|
|
80
|
+
this.instanceID = options.instanceID ?? randomUUID()
|
|
81
|
+
const platform = options.platform ?? { os: osPlatform(), arch: osArch() }
|
|
82
|
+
this.labels = { os: platform.os, arch: platform.arch, ...options.config.labels }
|
|
83
|
+
for (const r of options.recovered ?? [])
|
|
84
|
+
this.launches.set(r.launchID, { phase: r.phase, ...(r.directory ? { directory: r.directory } : {}), ...(r.sandbox ? { sandbox: r.sandbox } : {}) })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
start(): void {
|
|
88
|
+
if (this.connection) return
|
|
89
|
+
const connectionOptions: NodeConnectionOptions = {
|
|
90
|
+
server: this.options.credentials.server,
|
|
91
|
+
token: this.options.credentials.token,
|
|
92
|
+
hello: () => this.hello(),
|
|
93
|
+
handle: (req) => this.handle(req),
|
|
94
|
+
...(this.options.onState ? { onState: this.options.onState } : {}),
|
|
95
|
+
}
|
|
96
|
+
this.connection = (this.options.connect ?? ((o) => new NodeConnection(o)))(connectionOptions)
|
|
97
|
+
this.connection.start()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop(): void {
|
|
101
|
+
for (const launch of this.launches.values()) launch.abort?.abort()
|
|
102
|
+
this.connection?.stop()
|
|
103
|
+
this.connection = undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Takes no new launches (running ones finish); the hub learns it from a capacity frame. */
|
|
107
|
+
drain(): void {
|
|
108
|
+
this.draining = true
|
|
109
|
+
this.sendCapacity()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
hello(): Omit<NodeHello, "t" | "v" | "role"> {
|
|
113
|
+
const features = NODE_FEATURES.filter((f) => f !== "sandbox" || this.servesSandbox())
|
|
114
|
+
return {
|
|
115
|
+
instanceID: this.instanceID,
|
|
116
|
+
nodeVersion: NODE_VERSION,
|
|
117
|
+
...(features.length ? { features } : {}),
|
|
118
|
+
runtimes: this.runtimes.map((r) => this.runtimeInfo(r)),
|
|
119
|
+
labels: this.labels,
|
|
120
|
+
capacity: this.capacity(),
|
|
121
|
+
used: this.used(),
|
|
122
|
+
workspaceRoots: [...this.options.config.workspaceRoots],
|
|
123
|
+
draining: this.draining,
|
|
124
|
+
launches: [...this.launches].map(
|
|
125
|
+
([launchID, t]): NodeLaunchReport => ({ launchID, phase: t.phase, ...(t.directory ? { directory: t.directory } : {}), ...(t.sandbox ? { sandbox: t.sandbox } : {}) }),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async handle(req: NodeRequest): Promise<NodeResultFor[NodeRequest["op"]]> {
|
|
131
|
+
switch (req.op) {
|
|
132
|
+
case "prepare":
|
|
133
|
+
return this.prepare(req)
|
|
134
|
+
case "collect": {
|
|
135
|
+
const runtime = this.workspaceRuntime(req.workspace)
|
|
136
|
+
const before = this.launches.get(req.launchID)?.phase
|
|
137
|
+
this.phase(req.launchID, "collecting")
|
|
138
|
+
try {
|
|
139
|
+
const artifacts = await runtime.collect({ launchID: req.launchID, workspace: req.workspace })
|
|
140
|
+
this.phase(req.launchID, "collected")
|
|
141
|
+
return { artifacts }
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
const tracked = this.launches.get(req.launchID)
|
|
144
|
+
if (tracked && before) tracked.phase = before
|
|
145
|
+
throw cause
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
case "cleanup": {
|
|
149
|
+
const runtime = this.workspaceRuntime(req.workspace)
|
|
150
|
+
this.phase(req.launchID, "cleaning")
|
|
151
|
+
try {
|
|
152
|
+
const result = await runtime.cleanup({ launchID: req.launchID, workspace: req.workspace }, req.policy)
|
|
153
|
+
this.progress(req.launchID, "cleaned", result.removed ? undefined : result.reason)
|
|
154
|
+
return result
|
|
155
|
+
} finally {
|
|
156
|
+
this.release(req.launchID)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
case "cancel": {
|
|
160
|
+
const tracked = this.launches.get(req.launchID)
|
|
161
|
+
if (!tracked?.abort || tracked.phase !== "preparing") return { cancelled: false }
|
|
162
|
+
tracked.abort.abort()
|
|
163
|
+
return { cancelled: true }
|
|
164
|
+
}
|
|
165
|
+
case "start":
|
|
166
|
+
return this.startLaunch(req)
|
|
167
|
+
case "stop":
|
|
168
|
+
return this.stopLaunch(req)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async startLaunch(req: Extract<NodeRequest, { op: "start" }>): Promise<NodeResultFor["start"]> {
|
|
173
|
+
const runtime = this.workspaceRuntime(req.workspace)
|
|
174
|
+
if (!runtime.start) return { started: false, reason: `the ${runtime.kind} runtime does not start harnesses (its sessions run in the machine's harness service)` }
|
|
175
|
+
const before = this.launches.get(req.launchID)?.phase
|
|
176
|
+
this.phase(req.launchID, "starting")
|
|
177
|
+
try {
|
|
178
|
+
const result = await runtime.start(req.workspace, { launchID: req.launchID, harness: req.harness, hub: req.hub, ...(req.env ? { env: req.env } : {}) }, {
|
|
179
|
+
progress: (message) => this.progress(req.launchID, "starting", message),
|
|
180
|
+
})
|
|
181
|
+
this.phase(req.launchID, "running")
|
|
182
|
+
return { started: result.started, ...(result.reason ? { reason: result.reason } : {}) }
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
const tracked = this.launches.get(req.launchID)
|
|
185
|
+
if (tracked && before) tracked.phase = before
|
|
186
|
+
this.progress(req.launchID, "failed", errorText(cause))
|
|
187
|
+
throw cause
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async stopLaunch(req: Extract<NodeRequest, { op: "stop" }>): Promise<NodeResultFor["stop"]> {
|
|
192
|
+
const runtime = this.workspaceRuntime(req.workspace)
|
|
193
|
+
if (!runtime.stop) return { stopped: false, reason: `the ${runtime.kind} runtime runs no harness of its own to stop` }
|
|
194
|
+
const before = this.launches.get(req.launchID)?.phase
|
|
195
|
+
this.phase(req.launchID, "stopping")
|
|
196
|
+
try {
|
|
197
|
+
const result = await runtime.stop({ launchID: req.launchID, workspace: req.workspace }, req.mode ?? "graceful")
|
|
198
|
+
this.phase(req.launchID, result.stopped ? "stopped" : (before ?? "stopped"))
|
|
199
|
+
return { stopped: result.stopped, ...(result.reason ? { reason: result.reason } : {}) }
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
const tracked = this.launches.get(req.launchID)
|
|
202
|
+
if (tracked && before) tracked.phase = before
|
|
203
|
+
throw cause
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async prepare(req: Extract<NodeRequest, { op: "prepare" }>): Promise<NodeResultFor["prepare"]> {
|
|
208
|
+
if (this.draining) throw new RuntimeError("this node is draining and takes no new launches")
|
|
209
|
+
if (this.launches.has(req.launchID)) throw new RuntimeError(`launch ${req.launchID} is already on this node`)
|
|
210
|
+
const runtime = this.runtimeFor(req.runtime.kind)
|
|
211
|
+
if (this.used().slots >= this.options.config.slots) throw new RuntimeError(`no free slot on this node (${this.options.config.slots} in use)`)
|
|
212
|
+
const abort = new AbortController()
|
|
213
|
+
this.launches.set(req.launchID, { phase: "preparing", abort })
|
|
214
|
+
this.sendCapacity()
|
|
215
|
+
this.progress(req.launchID, "preparing")
|
|
216
|
+
try {
|
|
217
|
+
const workspace = await runtime.prepare(
|
|
218
|
+
{ launchID: req.launchID, harness: req.harness, runtime: req.runtime, workspace: req.workspace },
|
|
219
|
+
{ signal: abort.signal, progress: (message) => this.progress(req.launchID, "preparing", message) },
|
|
220
|
+
)
|
|
221
|
+
if (abort.signal.aborted) throw new RuntimeError("prepare cancelled")
|
|
222
|
+
this.launches.set(req.launchID, { phase: "prepared", directory: workspace.directory, ...(workspace.runtime?.sandbox ? { sandbox: workspace.runtime.sandbox } : {}) })
|
|
223
|
+
this.progress(req.launchID, "prepared")
|
|
224
|
+
return { workspace }
|
|
225
|
+
} catch (cause) {
|
|
226
|
+
this.progress(req.launchID, abort.signal.aborted ? "cancelled" : "failed", abort.signal.aborted ? undefined : errorText(cause))
|
|
227
|
+
this.release(req.launchID)
|
|
228
|
+
throw abort.signal.aborted ? new RuntimeError("prepare cancelled") : cause
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private runtimeFor(kind: string): Runtime {
|
|
233
|
+
const runtime = this.runtimes.find((r) => r.kind === kind)
|
|
234
|
+
if (!runtime) throw new RuntimeError(`runtime ${kind || "(none)"} is not available on this node`)
|
|
235
|
+
return runtime
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The runtime that owns a prepared workspace: the one it names (absent: host). */
|
|
239
|
+
private workspaceRuntime(workspace: PreparedWorkspace): Runtime {
|
|
240
|
+
return this.runtimeFor(preparedWorkspaceRuntime(workspace).kind)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Whether a runtime here starts and stops harnesses itself (the "sandbox" feature). */
|
|
244
|
+
private servesSandbox(): boolean {
|
|
245
|
+
return this.runtimes.some((r) => typeof r.start === "function" && typeof r.stop === "function")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private runtimeInfo(runtime: Runtime): NodeRuntimeInfo {
|
|
249
|
+
const info = runtime.info()
|
|
250
|
+
const backends = this.options.backends?.[runtime.kind]
|
|
251
|
+
return backends?.length ? { ...info, backends: [...backends] } : info
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private capacity(): NodeCapacity {
|
|
255
|
+
const { slots, memoryMB } = this.options.config
|
|
256
|
+
return { slots, ...(memoryMB !== undefined ? { memoryMB } : {}) }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private used(): NodeCapacity {
|
|
260
|
+
return { slots: this.launches.size }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private phase(launchID: string, phase: NodeLaunchPhase): void {
|
|
264
|
+
const tracked = this.launches.get(launchID)
|
|
265
|
+
if (tracked) tracked.phase = phase
|
|
266
|
+
this.progress(launchID, phase)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private release(launchID: string): void {
|
|
270
|
+
if (this.launches.delete(launchID)) this.sendCapacity()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private progress(launchID: string, phase: NodeLaunchPhase, message?: string): void {
|
|
274
|
+
const text = message === undefined ? undefined : progressText(message)
|
|
275
|
+
this.connection?.send({ t: "progress", launchID, phase, ...(text ? { message: text } : {}) })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private sendCapacity(): void {
|
|
279
|
+
this.connection?.send({ t: "capacity", capacity: this.capacity(), used: this.used(), draining: this.draining })
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The node's only way to run git (decision 10). Never a shell: spawn (execFile semantics, no shell) with an argv the caller builds with `--`
|
|
2
|
+
// before paths and refs. The environment is fixed: no terminal or credential-manager prompts (a daemon has no one to
|
|
3
|
+
// ask), only the https and ssh transports (never ext::, file:: or a local path as a remote), and none of the
|
|
4
|
+
// inherited GIT_* variables that redirect a repository, its config or git's own programs. Every run has a time limit
|
|
5
|
+
// and can be cancelled. node:child_process (not Bun.spawn) so the node also runs bundled for node.
|
|
6
|
+
import { spawn } from "node:child_process"
|
|
7
|
+
|
|
8
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
9
|
+
|
|
10
|
+
/** Transports git may use (GIT_ALLOW_PROTOCOL). */
|
|
11
|
+
export const ALLOWED_PROTOCOLS = "https:ssh"
|
|
12
|
+
export const GIT_TIMEOUT_MS = 120_000
|
|
13
|
+
const MAX_BUFFER = 16 * 1024 * 1024
|
|
14
|
+
const STDERR_MAX = 600
|
|
15
|
+
|
|
16
|
+
/** Inherited GIT_* names that only choose how ssh runs; every other GIT_* name is dropped. */
|
|
17
|
+
const KEPT_GIT_VARS = new Set(["GIT_SSH", "GIT_SSH_COMMAND", "GIT_SSH_VARIANT"])
|
|
18
|
+
|
|
19
|
+
export class GitError extends Error {
|
|
20
|
+
override readonly name = "GitError"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `base` without its GIT_* overrides, plus the hardened settings and then `overlay`. */
|
|
24
|
+
export function gitEnv(base: Env = process.env, overlay: Env = {}): Record<string, string> {
|
|
25
|
+
const env: Record<string, string> = {}
|
|
26
|
+
for (const [name, value] of Object.entries(base)) {
|
|
27
|
+
if (value === undefined) continue
|
|
28
|
+
if (name.startsWith("GIT_") && !KEPT_GIT_VARS.has(name)) continue
|
|
29
|
+
env[name] = value
|
|
30
|
+
}
|
|
31
|
+
Object.assign(env, { GIT_TERMINAL_PROMPT: "0", GIT_ALLOW_PROTOCOL: ALLOWED_PROTOCOLS, GCM_INTERACTIVE: "never", LC_ALL: "C" })
|
|
32
|
+
for (const [name, value] of Object.entries(overlay)) if (value !== undefined) env[name] = value
|
|
33
|
+
return env
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Removes credentials (userinfo) from URLs in git output. */
|
|
37
|
+
export function redact(text: string): string {
|
|
38
|
+
return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GitOptions {
|
|
42
|
+
readonly cwd: string
|
|
43
|
+
readonly timeoutMs?: number
|
|
44
|
+
readonly signal?: AbortSignal
|
|
45
|
+
/** The environment to start from (default process.env); GIT_* overrides are dropped from it. */
|
|
46
|
+
readonly baseEnv?: Env
|
|
47
|
+
/** Applied after the hardening (tests use it to allow the file transport). */
|
|
48
|
+
readonly env?: Env
|
|
49
|
+
/** The git executable (default "git" on PATH). */
|
|
50
|
+
readonly binary?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Runs `git -c protocol.ext.allow=never <args>` and resolves with stdout; rejects with a GitError. */
|
|
54
|
+
export function runGit(args: readonly string[], options: GitOptions): Promise<string> {
|
|
55
|
+
const argv = ["-c", "protocol.ext.allow=never", ...args]
|
|
56
|
+
const what = `git ${args.find((a) => !a.startsWith("-")) ?? ""}`.trim()
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
if (options.signal?.aborted) return reject(new GitError(`${what} cancelled`))
|
|
59
|
+
let settled = false
|
|
60
|
+
const finish = (error?: GitError, stdout?: string) => {
|
|
61
|
+
if (settled) return
|
|
62
|
+
settled = true
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
options.signal?.removeEventListener("abort", onAbort)
|
|
65
|
+
if (error) reject(error)
|
|
66
|
+
else resolve(stdout ?? "")
|
|
67
|
+
}
|
|
68
|
+
// Its own process group, so a timeout or cancel also kills what git started (ssh, git-remote-https).
|
|
69
|
+
const child = spawn(options.binary ?? "git", argv, {
|
|
70
|
+
cwd: options.cwd,
|
|
71
|
+
env: gitEnv(options.baseEnv ?? process.env, options.env),
|
|
72
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
73
|
+
detached: true,
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
})
|
|
76
|
+
const out: Buffer[] = []
|
|
77
|
+
const err: Buffer[] = []
|
|
78
|
+
let size = 0
|
|
79
|
+
const collect = (into: Buffer[]) => (chunk: Buffer) => {
|
|
80
|
+
size += chunk.length
|
|
81
|
+
if (size > MAX_BUFFER) return stop("printed too much output")
|
|
82
|
+
into.push(chunk)
|
|
83
|
+
}
|
|
84
|
+
child.stdout.on("data", collect(out))
|
|
85
|
+
child.stderr.on("data", collect(err))
|
|
86
|
+
child.on("error", (cause) => finish(new GitError(`${what} failed: ${cause.message}`)))
|
|
87
|
+
child.on("close", (code, signal) => {
|
|
88
|
+
if (code === 0) return finish(undefined, Buffer.concat(out).toString("utf8"))
|
|
89
|
+
const detail = redact(Buffer.concat(err).toString("utf8").trim()).slice(0, STDERR_MAX) || (signal ? `killed by ${signal}` : `exit ${code}`)
|
|
90
|
+
finish(new GitError(`${what} failed: ${detail}`))
|
|
91
|
+
})
|
|
92
|
+
// Settles at once: a killed git's children may hold its output pipes open for a while.
|
|
93
|
+
function stop(why: string): void {
|
|
94
|
+
if (settled) return
|
|
95
|
+
try {
|
|
96
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL")
|
|
97
|
+
} catch {
|
|
98
|
+
child.kill("SIGKILL")
|
|
99
|
+
}
|
|
100
|
+
finish(new GitError(`${what} ${why}`))
|
|
101
|
+
}
|
|
102
|
+
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUT_MS
|
|
103
|
+
const timer = setTimeout(() => stop(`timed out after ${Math.round(timeoutMs / 1000) || 1} s`), timeoutMs)
|
|
104
|
+
const onAbort = () => stop("cancelled")
|
|
105
|
+
options.signal?.addEventListener("abort", onAbort, { once: true })
|
|
106
|
+
})
|
|
107
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Which harnesses the host runtime offers (the node hello's runtimes[host].harnesses, design §6.3). The host
|
|
2
|
+
// runtime starts nothing itself: a host launch's session is created by the machine's service spoke of its harness
|
|
3
|
+
// (placement also needs that spoke connected with "launch"), so this only says what can run here:
|
|
4
|
+
// - opencode: always (its service spoke is the OpenCode plugin, which the node cannot see);
|
|
5
|
+
// - claude-code: when the Claude Code CLI is found: HUB_CLAUDE_BIN (absolute, or a name on PATH), else `claude` on
|
|
6
|
+
// PATH; the Claude Code spoke daemon (`hub claude-code daemon`) creates its launch sessions;
|
|
7
|
+
// - node.json `harnesses` overrides detection either way (true offers a harness, false withholds it).
|
|
8
|
+
import { accessSync, constants, statSync } from "node:fs"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
|
|
11
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
12
|
+
|
|
13
|
+
/** The order harnesses are listed in; others follow in node.json's order. */
|
|
14
|
+
const KNOWN = ["opencode", "claude-code", "codex"]
|
|
15
|
+
|
|
16
|
+
export interface DetectHarnessesOptions {
|
|
17
|
+
/** The environment to look in (PATH, HUB_CLAUDE_BIN); default process.env. */
|
|
18
|
+
readonly env?: Env
|
|
19
|
+
/** node.json `harnesses`. */
|
|
20
|
+
readonly overrides?: Readonly<Record<string, boolean>>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function executable(file: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
if (!statSync(file).isFile()) return false
|
|
26
|
+
accessSync(file, constants.X_OK)
|
|
27
|
+
return true
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The executable `name` runs as: an absolute path checked as it is, else the first match on PATH. */
|
|
34
|
+
export function findExecutable(name: string, env: Env): string | undefined {
|
|
35
|
+
if (!name) return undefined
|
|
36
|
+
if (path.isAbsolute(name)) return executable(name) ? name : undefined
|
|
37
|
+
if (name.includes("/")) return undefined
|
|
38
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
39
|
+
if (!dir || !path.isAbsolute(dir)) continue
|
|
40
|
+
const file = path.join(dir, name)
|
|
41
|
+
if (executable(file)) return file
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The harnesses this machine's host runtime offers. */
|
|
47
|
+
export function detectHarnesses(options: DetectHarnessesOptions = {}): string[] {
|
|
48
|
+
const env = options.env ?? process.env
|
|
49
|
+
const found = new Set<string>(["opencode"])
|
|
50
|
+
if (findExecutable(env.HUB_CLAUDE_BIN || "claude", env)) found.add("claude-code")
|
|
51
|
+
for (const [name, on] of Object.entries(options.overrides ?? {})) {
|
|
52
|
+
if (on) found.add(name)
|
|
53
|
+
else found.delete(name)
|
|
54
|
+
}
|
|
55
|
+
const rank = (name: string) => {
|
|
56
|
+
const i = KNOWN.indexOf(name)
|
|
57
|
+
return i < 0 ? KNOWN.length : i
|
|
58
|
+
}
|
|
59
|
+
return [...found].sort((a, b) => rank(a) - rank(b))
|
|
60
|
+
}
|