@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,475 @@
|
|
|
1
|
+
// The `openshell` runtime of `hub node` (design §6.4, decision lk6gn3u7): a launch runs in an NVIDIA OpenShell
|
|
2
|
+
// sandbox the node creates through the openshell CLI (OpenShellClient).
|
|
3
|
+
//
|
|
4
|
+
// prepare clones the workspace on the host under the node's roots (HostRuntime, with the node's git credentials),
|
|
5
|
+
// creates the sandbox detached in the OpenShell workspace `hub` (compiled policy, the profile's providers,
|
|
6
|
+
// labels hub.launch/hub.machine/hub.owner), waits for Ready, uploads a git bundle of the clone and clones it
|
|
7
|
+
// inside at /sandbox/work. No git credentials ever enter the sandbox.
|
|
8
|
+
//
|
|
9
|
+
// start writes the harness's hub config into the sandbox (.hub/config.json: the hub URL as the sandbox reaches it,
|
|
10
|
+
// the launch-bound token and the launch ID; .hub/node.json: the workspace root; .hub/env.json: extra env),
|
|
11
|
+
// uploaded as files so the token is never in an argv, then touches .hub/ready, which the image's entry
|
|
12
|
+
// script waits for before it runs the harness.
|
|
13
|
+
// stop writes .hub/stop holding the mode (graceful or kill) for the entry script; the sandbox keeps running,
|
|
14
|
+
// so collect can still read it.
|
|
15
|
+
// collect bundles the launch's branch inside (only the commits the host clone lacks), downloads and verifies the
|
|
16
|
+
// bundle and fast-forwards the host clone (never forced: a diverged branch fails collect); dirty means
|
|
17
|
+
// uncommitted work left in the sandbox. A gone sandbox leaves the host clone as the record.
|
|
18
|
+
// cleanup deletes the sandbox only when nothing would be lost (policy remove, no uncommitted work, every commit
|
|
19
|
+
// collected), else stops it and says why; the host clone goes through HostRuntime's rules.
|
|
20
|
+
//
|
|
21
|
+
// Names are deterministic (hub-<15 hex of sha256(launchID)>, OpenShell allows 19 characters) and every op checks the sandbox's hub.launch label,
|
|
22
|
+
// so a restarted node finds its own sandboxes again and never touches anyone else's.
|
|
23
|
+
import { createHash } from "node:crypto"
|
|
24
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
25
|
+
import { tmpdir } from "node:os"
|
|
26
|
+
import path from "node:path"
|
|
27
|
+
import { launchWorkspaceProblem, preparedWorkspaceProblem, type CleanupPolicy, type LaunchArtifacts, type NodeRuntimeInfo, type PreparedWorkspace } from "../../protocol/src/index.ts"
|
|
28
|
+
import {
|
|
29
|
+
RuntimeError,
|
|
30
|
+
type CleanupResult,
|
|
31
|
+
type PrepareInput,
|
|
32
|
+
type RunHandle,
|
|
33
|
+
type Runtime,
|
|
34
|
+
type RuntimeContext,
|
|
35
|
+
type StartInput,
|
|
36
|
+
type StartResult,
|
|
37
|
+
type StopMode,
|
|
38
|
+
type StopResult,
|
|
39
|
+
} from "../../runtime-sdk/src/index.ts"
|
|
40
|
+
import { GitError, runGit, type GitOptions } from "../../node/src/git.ts"
|
|
41
|
+
import { HostRuntime, LAUNCH_MARKER, resolveUnderRoots } from "../../node/src/host.ts"
|
|
42
|
+
import pkg from "../package.json" with { type: "json" }
|
|
43
|
+
import { imageTag, startCommand } from "../image/image.ts"
|
|
44
|
+
import { OpenShellError, redact, type ExecResult, type OpenShellClient, type Sandbox } from "./cli.ts"
|
|
45
|
+
import { compilePolicy, sandboxHubEndpoint } from "./policy.ts"
|
|
46
|
+
import { normalizeOpenShellProfiles, profileNames, selectProfile, type OpenShellProfiles } from "./profiles.ts"
|
|
47
|
+
|
|
48
|
+
/** The sandbox's home: its working directory, where `.hub/` and the workspace live. */
|
|
49
|
+
export const SANDBOX_HOME = "/sandbox"
|
|
50
|
+
/** The session's directory inside the sandbox (the clone of the seed bundle). */
|
|
51
|
+
export const SANDBOX_WORKDIR = "/sandbox/work"
|
|
52
|
+
/** Hub files inside the sandbox, relative to SANDBOX_HOME. */
|
|
53
|
+
export const HUB_DIR = ".hub"
|
|
54
|
+
export const SEED_BUNDLE = `${HUB_DIR}/seed.bundle`
|
|
55
|
+
export const OUT_BUNDLE = `${HUB_DIR}/out.bundle`
|
|
56
|
+
/** Where collected branches land in the host clone before the fast-forward. */
|
|
57
|
+
const COLLECTED_REFS = "refs/hub/sandbox"
|
|
58
|
+
/** The spoke's launch config inside (src/config.ts parseLaunchConfig): { hubURL, token, launchID }. */
|
|
59
|
+
export const HUB_CONFIG_FILE = `${HUB_DIR}/config.json`
|
|
60
|
+
/** node.json next to it: { workspaceRoots: [SANDBOX_WORKDIR] }. */
|
|
61
|
+
export const HUB_NODE_FILE = `${HUB_DIR}/node.json`
|
|
62
|
+
/** The start op's extra environment for the harness, as a JSON object. */
|
|
63
|
+
export const HUB_ENV_FILE = `${HUB_DIR}/env.json`
|
|
64
|
+
/** Touched last by start: the entry script runs the harness once it exists. */
|
|
65
|
+
export const READY_FILE = `${HUB_DIR}/ready`
|
|
66
|
+
/** Written by stop with the mode ("graceful" or "kill"). */
|
|
67
|
+
export const STOP_FILE = `${HUB_DIR}/stop`
|
|
68
|
+
/** The image a profile without `image` gets (built by packages/runtime-openshell/image). */
|
|
69
|
+
/** The image build/image.ts tags for this package's version (it must be published to an HTTPS registry). */
|
|
70
|
+
export const DEFAULT_SANDBOX_IMAGE = imageTag(pkg.version)
|
|
71
|
+
/** The image's start script: starts the harness detached once .hub/config.json and .hub/ready exist. */
|
|
72
|
+
export const DEFAULT_START_COMMAND: readonly string[] = startCommand()
|
|
73
|
+
/** Non-secret environment every sandbox gets: the harness stays offline but for the hub and its providers. */
|
|
74
|
+
export const SANDBOX_ENV: Readonly<Record<string, string>> = {
|
|
75
|
+
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
|
76
|
+
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
|
77
|
+
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
78
|
+
HUB_LAUNCH_CONFIG: `${SANDBOX_HOME}/${HUB_DIR}/config.json`,
|
|
79
|
+
}
|
|
80
|
+
export const READY_TIMEOUT_MS = 300_000
|
|
81
|
+
const POLL_MS = 1_000
|
|
82
|
+
const LABEL_VALUE = /^[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/
|
|
83
|
+
const REASON_MAX = 300
|
|
84
|
+
|
|
85
|
+
export interface OpenShellRuntimeOptions {
|
|
86
|
+
readonly client: OpenShellClient
|
|
87
|
+
/** node.json workspaceRoots: the host clones live under them. */
|
|
88
|
+
readonly roots: readonly string[]
|
|
89
|
+
/** The hub URL as the node knows it (config.json `server`); the policy allows it. */
|
|
90
|
+
readonly hubURL: string
|
|
91
|
+
/** node.json `openshell.profiles`, normalized (default: the built-in default profile only). */
|
|
92
|
+
readonly profiles?: OpenShellProfiles
|
|
93
|
+
/** The image for profiles without one (default DEFAULT_SANDBOX_IMAGE). */
|
|
94
|
+
readonly image?: string
|
|
95
|
+
/** The argv that starts the harness after the ready file (default DEFAULT_START_COMMAND). */
|
|
96
|
+
readonly startCommand?: readonly string[]
|
|
97
|
+
/** Harnesses the image runs (default opencode). */
|
|
98
|
+
readonly harnesses?: readonly string[]
|
|
99
|
+
/** Label values: this machine and its owner (hub.machine, hub.owner). */
|
|
100
|
+
readonly machineID?: string
|
|
101
|
+
readonly owner?: string
|
|
102
|
+
/** Host git settings (environment, binary, time limit); tests use them, the daemon passes none. */
|
|
103
|
+
readonly git?: Omit<GitOptions, "cwd" | "signal">
|
|
104
|
+
/** Sandbox phase polling interval and limit. */
|
|
105
|
+
readonly pollMs?: number
|
|
106
|
+
readonly readyTimeoutMs?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sha = (s: string, hex = 20) => createHash("sha256").update(s).digest("hex").slice(0, hex)
|
|
110
|
+
/** OpenShell's limit for sandbox names (the gateway's MAX_ROUTABLE_NAME_LEN): "hub-" + 15 hex. */
|
|
111
|
+
export const SANDBOX_NAME_MAX = 19
|
|
112
|
+
|
|
113
|
+
/** The launch's sandbox: hub-<15 hex of sha256(launchID)>, a lowercase DNS label within SANDBOX_NAME_MAX. */
|
|
114
|
+
export function sandboxName(launchID: string): string {
|
|
115
|
+
return `hub-${sha(launchID, SANDBOX_NAME_MAX - 4)}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A label value for an ID: the ID itself when it is a valid label value, else 20 hex of its sha256. */
|
|
119
|
+
export function labelValue(id: string): string {
|
|
120
|
+
return LABEL_VALUE.test(id) ? id : sha(id)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The host side of a sandboxed workspace: the clone at `repo`, as HostRuntime made it. */
|
|
124
|
+
function hostWorkspace(ws: PreparedWorkspace): PreparedWorkspace {
|
|
125
|
+
const { runtime: _runtime, ...rest } = ws
|
|
126
|
+
return { ...rest, directory: ws.repo }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cut = (s: string) => (s.length > REASON_MAX ? `${s.slice(0, REASON_MAX)}…` : s)
|
|
130
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
131
|
+
|
|
132
|
+
export class OpenShellRuntime implements Runtime {
|
|
133
|
+
readonly kind = "openshell"
|
|
134
|
+
private readonly client: OpenShellClient
|
|
135
|
+
private readonly roots: readonly string[]
|
|
136
|
+
private readonly profiles: OpenShellProfiles
|
|
137
|
+
private readonly harnesses: readonly string[]
|
|
138
|
+
private readonly host: HostRuntime
|
|
139
|
+
private readonly pollMs: number
|
|
140
|
+
private readonly readyTimeoutMs: number
|
|
141
|
+
|
|
142
|
+
constructor(private readonly options: OpenShellRuntimeOptions) {
|
|
143
|
+
this.client = options.client
|
|
144
|
+
this.roots = options.roots
|
|
145
|
+
this.profiles = options.profiles ?? normalizeOpenShellProfiles(undefined)
|
|
146
|
+
this.harnesses = options.harnesses ?? ["opencode"]
|
|
147
|
+
this.host = new HostRuntime({ roots: options.roots, harnesses: this.harnesses, ...(options.git ? { git: options.git } : {}) })
|
|
148
|
+
this.pollMs = options.pollMs ?? POLL_MS
|
|
149
|
+
this.readyTimeoutMs = options.readyTimeoutMs ?? READY_TIMEOUT_MS
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
info(): NodeRuntimeInfo {
|
|
153
|
+
return { kind: this.kind, harnesses: [...this.harnesses], profiles: profileNames(this.profiles) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- prepare ----------
|
|
157
|
+
|
|
158
|
+
async prepare(input: PrepareInput, ctx: RuntimeContext = {}): Promise<PreparedWorkspace> {
|
|
159
|
+
if (ctx.signal?.aborted) throw new RuntimeError("prepare cancelled")
|
|
160
|
+
if (input.runtime?.kind !== this.kind) throw new RuntimeError(`this runtime is openshell, not ${String(input.runtime?.kind)}`)
|
|
161
|
+
if (!this.harnesses.includes(input.harness)) throw new RuntimeError(`harness ${input.harness} does not run in this node's sandboxes (they have ${this.harnesses.join(", ")})`)
|
|
162
|
+
const problem = launchWorkspaceProblem(input.workspace)
|
|
163
|
+
if (problem) throw new RuntimeError(problem)
|
|
164
|
+
if (input.workspace.mode !== "clone") throw new RuntimeError(`an openshell sandbox takes clone workspaces only, not ${input.workspace.mode}`)
|
|
165
|
+
const profile = selectProfile(this.profiles, input.runtime.profile)
|
|
166
|
+
const policy = compilePolicy({ hubURL: this.options.hubURL, profile, ...(input.workspace.remote ? { gitRemote: input.workspace.remote } : {}) })
|
|
167
|
+
const name = sandboxName(input.launchID)
|
|
168
|
+
|
|
169
|
+
const existing = await this.owned(name, input.launchID, ctx)
|
|
170
|
+
const host = await this.hostClone(input, ctx)
|
|
171
|
+
if (!existing) {
|
|
172
|
+
ctx.progress?.(`creating sandbox ${name}`)
|
|
173
|
+
await this.create(name, input.launchID, profile.image ?? this.options.image ?? DEFAULT_SANDBOX_IMAGE, policy.yaml, policy.providers, ctx)
|
|
174
|
+
}
|
|
175
|
+
await this.waitReady(name, ctx)
|
|
176
|
+
await this.seed(name, host, ctx)
|
|
177
|
+
return { ...host, directory: SANDBOX_WORKDIR, runtime: { kind: "openshell", sandbox: name, profile: profile.name } }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The launch's sandbox, or undefined when there is none; refuses one labelled for another launch. */
|
|
181
|
+
private async owned(name: string, launchID: string, ctx: RuntimeContext): Promise<Sandbox | undefined> {
|
|
182
|
+
const sandbox = await this.call(() => this.client.get(name, this.signal(ctx)))
|
|
183
|
+
if (!sandbox) return undefined
|
|
184
|
+
if (sandbox.labels["hub.launch"] !== labelValue(launchID)) throw new RuntimeError(`sandbox ${name} belongs to another launch`)
|
|
185
|
+
return sandbox
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The host clone: made by HostRuntime, or (a repeated prepare) the one this launch made before. */
|
|
189
|
+
private async hostClone(input: PrepareInput, ctx: RuntimeContext): Promise<PreparedWorkspace> {
|
|
190
|
+
const target = resolveUnderRoots(input.workspace.repo, this.roots)
|
|
191
|
+
if (!existsSync(target)) return this.host.prepare({ ...input, runtime: { kind: "host" } }, ctx)
|
|
192
|
+
if ((await this.marker(target, ctx)) !== input.launchID) throw new RuntimeError(`${target} exists and was not cloned by this launch`)
|
|
193
|
+
const head = (await this.git(target, ["rev-parse", "HEAD"], ctx)).trim()
|
|
194
|
+
const branch = (await this.gitOutput(target, ["symbolic-ref", "--quiet", "--short", "HEAD"], ctx))?.trim()
|
|
195
|
+
const { base } = input.workspace
|
|
196
|
+
return { repo: target, directory: target, mode: "clone", ...(branch ? { branch } : {}), ...(base ? { base } : {}), head, reused: false }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async create(name: string, launchID: string, image: string, policyYAML: string, providers: readonly string[], ctx: RuntimeContext): Promise<Sandbox> {
|
|
200
|
+
const labels: Record<string, string> = { "hub.launch": labelValue(launchID) }
|
|
201
|
+
if (this.options.machineID) labels["hub.machine"] = labelValue(this.options.machineID)
|
|
202
|
+
if (this.options.owner) labels["hub.owner"] = labelValue(this.options.owner)
|
|
203
|
+
return this.withTemp(async (dir) => {
|
|
204
|
+
const policyFile = path.join(dir, "policy.yaml")
|
|
205
|
+
writeFileSync(policyFile, policyYAML, { mode: 0o600 })
|
|
206
|
+
try {
|
|
207
|
+
return await this.call(() => this.client.create({ name, from: image, policyFile, providers, labels, env: SANDBOX_ENV }, this.signal(ctx)))
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// Another prepare of this launch created it meanwhile.
|
|
210
|
+
const again = await this.owned(name, launchID, ctx)
|
|
211
|
+
if (again) return again
|
|
212
|
+
throw error
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Polls until the sandbox is Ready; a RuntimeError when it fails, goes away or takes too long. */
|
|
218
|
+
private async waitReady(name: string, ctx: RuntimeContext): Promise<void> {
|
|
219
|
+
const deadline = Date.now() + this.readyTimeoutMs
|
|
220
|
+
for (;;) {
|
|
221
|
+
if (ctx.signal?.aborted) throw new RuntimeError("prepare cancelled")
|
|
222
|
+
const sandbox = await this.call(() => this.client.get(name, this.signal(ctx)))
|
|
223
|
+
if (!sandbox) throw new RuntimeError(`sandbox ${name} is gone`)
|
|
224
|
+
if (sandbox.phase === "Ready") return
|
|
225
|
+
if (sandbox.phase !== "Provisioning" && sandbox.phase !== "Starting" && sandbox.phase !== "Unspecified") {
|
|
226
|
+
const why = sandbox.conditions.find((c) => c.message)?.message
|
|
227
|
+
throw new RuntimeError(cut(`sandbox ${name} is ${sandbox.phase}${why ? `: ${why}` : ""}`))
|
|
228
|
+
}
|
|
229
|
+
if (Date.now() > deadline) throw new RuntimeError(`sandbox ${name} was not ready after ${Math.round(this.readyTimeoutMs / 1000)} s`)
|
|
230
|
+
await sleep(this.pollMs)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Clones the host clone into /sandbox/work through a bundle, unless a clone is already there. */
|
|
235
|
+
private async seed(name: string, host: PreparedWorkspace, ctx: RuntimeContext): Promise<void> {
|
|
236
|
+
if ((await this.exec(name, ["git", "-C", "work", "rev-parse", "HEAD"], ctx)).exitCode === 0) return
|
|
237
|
+
ctx.progress?.("seeding the sandbox")
|
|
238
|
+
await this.withTemp(async (dir) => {
|
|
239
|
+
const bundle = path.join(dir, "seed.bundle")
|
|
240
|
+
await this.git(host.directory, ["bundle", "create", bundle, "HEAD", "--branches"], ctx)
|
|
241
|
+
await this.mustExec(name, ["mkdir", "-p", HUB_DIR], ctx)
|
|
242
|
+
await this.call(() => this.client.upload(name, bundle, `${SANDBOX_HOME}/${HUB_DIR}/`, this.signal(ctx)))
|
|
243
|
+
})
|
|
244
|
+
await this.mustExec(name, ["git", "clone", "--quiet", ...(host.branch ? ["--branch", host.branch] : []), "--", SEED_BUNDLE, "work"], ctx)
|
|
245
|
+
const head = (await this.mustExec(name, ["git", "-C", "work", "rev-parse", "HEAD"], ctx)).stdout.trim()
|
|
246
|
+
if (head !== host.head) throw new RuntimeError(`the sandbox clone is at ${head}, not ${host.head}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------- start / stop ----------
|
|
250
|
+
|
|
251
|
+
async start(workspace: PreparedWorkspace, input: StartInput, ctx: RuntimeContext = {}): Promise<StartResult> {
|
|
252
|
+
if (ctx.signal?.aborted) throw new RuntimeError("start cancelled")
|
|
253
|
+
if (!this.harnesses.includes(input.harness)) throw new RuntimeError(`harness ${input.harness} does not run in this node's sandboxes (they have ${this.harnesses.join(", ")})`)
|
|
254
|
+
const handle = { launchID: input.launchID, workspace }
|
|
255
|
+
const name = this.sandboxOf(handle)
|
|
256
|
+
const sandbox = await this.owned(name, input.launchID, ctx)
|
|
257
|
+
if (!sandbox) throw new RuntimeError(`sandbox ${name} is gone`)
|
|
258
|
+
if (sandbox.phase !== "Ready") throw new RuntimeError(`sandbox ${name} is ${sandbox.phase}, not Ready`)
|
|
259
|
+
if (await this.exists(name, READY_FILE, ctx)) return { ...handle, started: false, reason: "the harness is already started" }
|
|
260
|
+
const hide = [input.hub.token]
|
|
261
|
+
const config = { hubURL: sandboxHubEndpoint(input.hub.url).url, token: input.hub.token, launchID: input.launchID }
|
|
262
|
+
ctx.progress?.("starting the harness")
|
|
263
|
+
await this.upload(name, { [HUB_CONFIG_FILE]: config, [HUB_NODE_FILE]: { workspaceRoots: [SANDBOX_WORKDIR] }, [HUB_ENV_FILE]: input.env ?? {} }, ctx, hide)
|
|
264
|
+
await this.mustExec(name, ["touch", READY_FILE], ctx, hide)
|
|
265
|
+
await this.mustExec(name, [...(this.options.startCommand ?? DEFAULT_START_COMMAND)], ctx, hide)
|
|
266
|
+
return { ...handle, started: true }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async stop(handle: RunHandle, mode: StopMode = "graceful"): Promise<StopResult> {
|
|
270
|
+
const name = this.sandboxOf(handle)
|
|
271
|
+
const sandbox = await this.owned(name, handle.launchID, {})
|
|
272
|
+
if (!sandbox) return { stopped: false, reason: "the sandbox is gone" }
|
|
273
|
+
if (sandbox.phase !== "Ready") return { stopped: false, reason: `the sandbox is ${sandbox.phase}` }
|
|
274
|
+
if (!(await this.exists(name, READY_FILE, {}))) return { stopped: false, reason: "the harness was never started" }
|
|
275
|
+
await this.upload(name, { [STOP_FILE]: `${mode}\n` }, {})
|
|
276
|
+
return { stopped: true }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The handle's sandbox name, after checking the handle (it comes from the hub) names this launch's sandbox. */
|
|
280
|
+
private sandboxOf(handle: RunHandle): string {
|
|
281
|
+
const problem = preparedWorkspaceProblem(handle?.workspace)
|
|
282
|
+
if (problem) throw new RuntimeError(problem)
|
|
283
|
+
const runtime = handle.workspace.runtime
|
|
284
|
+
if (runtime?.kind !== this.kind || !runtime.sandbox) throw new RuntimeError("the workspace is not in an openshell sandbox")
|
|
285
|
+
const name = sandboxName(handle.launchID)
|
|
286
|
+
if (runtime.sandbox !== name) throw new RuntimeError(`sandbox ${runtime.sandbox} is not this launch's`)
|
|
287
|
+
return name
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Whether a path (relative to the sandbox home) exists in the sandbox. */
|
|
291
|
+
private async exists(name: string, rel: string, ctx: RuntimeContext): Promise<boolean> {
|
|
292
|
+
return (await this.exec(name, ["test", "-e", rel], ctx)).exitCode === 0
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Writes files (path under .hub → JSON value or text) into the sandbox's .hub directory. */
|
|
296
|
+
private async upload(name: string, files: Readonly<Record<string, unknown>>, ctx: RuntimeContext, hide: readonly string[] = []): Promise<void> {
|
|
297
|
+
await this.mustExec(name, ["mkdir", "-p", HUB_DIR], ctx, hide)
|
|
298
|
+
await this.withTemp(async (dir) => {
|
|
299
|
+
for (const [rel, value] of Object.entries(files)) {
|
|
300
|
+
const local = path.join(dir, path.posix.basename(rel))
|
|
301
|
+
writeFileSync(local, typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
|
|
302
|
+
await this.call(() => this.client.upload(name, local, `${SANDBOX_HOME}/${HUB_DIR}/`, this.signal(ctx)), hide)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------- collect / cleanup ----------
|
|
308
|
+
|
|
309
|
+
async collect(handle: RunHandle, ctx: RuntimeContext = {}): Promise<LaunchArtifacts> {
|
|
310
|
+
const name = this.sandboxOf(handle)
|
|
311
|
+
const host = hostWorkspace(handle.workspace)
|
|
312
|
+
const sandbox = await this.owned(name, handle.launchID, ctx)
|
|
313
|
+
let dirty = false
|
|
314
|
+
if (sandbox?.phase === "Ready") {
|
|
315
|
+
dirty = await this.isDirtyInside(name, ctx)
|
|
316
|
+
await this.fetchBack(name, host, ctx)
|
|
317
|
+
}
|
|
318
|
+
const artifacts = await this.host.collect({ launchID: handle.launchID, workspace: host }, ctx)
|
|
319
|
+
return { ...artifacts, dirty: dirty || artifacts.dirty === true }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Brings the sandbox's commits on the launch's branch into the host clone, fast-forward only. */
|
|
323
|
+
private async fetchBack(name: string, host: PreparedWorkspace, ctx: RuntimeContext): Promise<void> {
|
|
324
|
+
const ref = host.branch ? `refs/heads/${host.branch}` : "HEAD"
|
|
325
|
+
const insideHead = (await this.exec(name, ["git", "-C", "work", "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], ctx)).stdout.trim()
|
|
326
|
+
if (!insideHead) throw new RuntimeError(`the sandbox has no ${host.branch ? `branch ${host.branch}` : "HEAD"}`)
|
|
327
|
+
const hostHead = (await this.git(host.directory, ["rev-parse", "--verify", `${ref}^{commit}`], ctx)).trim()
|
|
328
|
+
if (insideHead === hostHead) return
|
|
329
|
+
ctx.progress?.("collecting the sandbox's commits")
|
|
330
|
+
// Only what the host lacks: the host head is the bundle's prerequisite (when the sandbox has it).
|
|
331
|
+
const known = (await this.exec(name, ["git", "-C", "work", "cat-file", "-e", `${hostHead}^{commit}`], ctx)).exitCode === 0
|
|
332
|
+
await this.mustExec(name, ["git", "-C", "work", "bundle", "create", `../${OUT_BUNDLE}`, ref, ...(known ? [`^${hostHead}`] : [])], ctx)
|
|
333
|
+
const target = `${COLLECTED_REFS}/${host.branch ?? "HEAD"}`
|
|
334
|
+
await this.withTemp(async (dir) => {
|
|
335
|
+
await this.call(() => this.client.download(name, `${SANDBOX_HOME}/${OUT_BUNDLE}`, dir, this.signal(ctx)))
|
|
336
|
+
const bundle = path.join(dir, path.posix.basename(OUT_BUNDLE))
|
|
337
|
+
if (!existsSync(bundle)) throw new RuntimeError("the sandbox's bundle did not arrive")
|
|
338
|
+
await this.git(host.directory, ["bundle", "verify", "--quiet", bundle], ctx)
|
|
339
|
+
// A bundle is read through the file transport; only for this fetch, from the node's own temporary file.
|
|
340
|
+
await this.git(host.directory, ["fetch", "--quiet", "--no-tags", bundle, `+${ref}:${target}`], ctx, { GIT_ALLOW_PROTOCOL: "file" })
|
|
341
|
+
})
|
|
342
|
+
const fetched = (await this.git(host.directory, ["rev-parse", "--verify", `${target}^{commit}`], ctx)).trim()
|
|
343
|
+
if (fetched !== insideHead) throw new RuntimeError(`the collected bundle holds ${fetched}, not ${insideHead}`)
|
|
344
|
+
if ((await this.gitOutput(host.directory, ["merge-base", "--is-ancestor", hostHead, fetched], ctx)) === undefined)
|
|
345
|
+
throw new RuntimeError(`the sandbox's ${host.branch ?? "HEAD"} does not fast-forward the host clone (it diverged)`)
|
|
346
|
+
const current = (await this.gitOutput(host.directory, ["symbolic-ref", "--quiet", "HEAD"], ctx))?.trim()
|
|
347
|
+
if (current === ref || ref === "HEAD") await this.git(host.directory, ["merge", "--ff-only", "--quiet", fetched], ctx)
|
|
348
|
+
else await this.git(host.directory, ["update-ref", ref, fetched, hostHead], ctx)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async cleanup(handle: RunHandle, policy: CleanupPolicy, ctx: RuntimeContext = {}): Promise<CleanupResult> {
|
|
352
|
+
const name = this.sandboxOf(handle)
|
|
353
|
+
const host = hostWorkspace(handle.workspace)
|
|
354
|
+
const sandbox = await this.cleanupSandbox(name, handle.launchID, host, policy, ctx)
|
|
355
|
+
const clone = await this.host.cleanup({ launchID: handle.launchID, workspace: host }, policy, ctx)
|
|
356
|
+
const reasons = [sandbox.reason && `sandbox: ${sandbox.reason}`, clone.reason && `host clone: ${clone.reason}`].filter(Boolean)
|
|
357
|
+
return sandbox.removed && clone.removed ? { removed: true } : { removed: false, reason: cut(reasons.join("; ")) }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Deletes the sandbox when nothing would be lost; else stops it and says why. */
|
|
361
|
+
private async cleanupSandbox(name: string, launchID: string, host: PreparedWorkspace, policy: CleanupPolicy, ctx: RuntimeContext): Promise<CleanupResult> {
|
|
362
|
+
const sandbox = await this.owned(name, launchID, ctx)
|
|
363
|
+
if (!sandbox) return { removed: true }
|
|
364
|
+
let keep: string | undefined
|
|
365
|
+
if (policy === "keep") keep = "cleanup policy is keep"
|
|
366
|
+
else if (sandbox.phase === "Ready") keep = await this.unsaved(name, host, ctx)
|
|
367
|
+
else if (sandbox.phase !== "Deleting") keep = `the sandbox is ${sandbox.phase}, so its work cannot be checked`
|
|
368
|
+
if (keep) {
|
|
369
|
+
if (sandbox.phase === "Ready" || sandbox.phase === "Starting" || sandbox.phase === "Provisioning") {
|
|
370
|
+
ctx.progress?.(`stopping sandbox ${name}`)
|
|
371
|
+
await this.call(() => this.client.stop(name, this.signal(ctx)))
|
|
372
|
+
}
|
|
373
|
+
return { removed: false, reason: keep }
|
|
374
|
+
}
|
|
375
|
+
ctx.progress?.(`deleting sandbox ${name}`)
|
|
376
|
+
if (sandbox.phase !== "Deleting") await this.call(() => this.client.delete(name, this.signal(ctx)))
|
|
377
|
+
await this.waitGone(name, ctx)
|
|
378
|
+
return { removed: true }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Why deleting the sandbox would lose work (uncommitted changes, commits the host clone lacks), or undefined. */
|
|
382
|
+
private async unsaved(name: string, host: PreparedWorkspace, ctx: RuntimeContext): Promise<string | undefined> {
|
|
383
|
+
const work = await this.exec(name, ["git", "-C", "work", "rev-parse", "--verify", "--quiet", "HEAD"], ctx)
|
|
384
|
+
if (work.exitCode !== 0) return undefined // never seeded: nothing of the launch's in it
|
|
385
|
+
if (await this.isDirtyInside(name, ctx)) return "the sandbox has uncommitted changes"
|
|
386
|
+
const heads = (await this.mustExec(name, ["git", "-C", "work", "for-each-ref", "--format=%(objectname)", "refs/heads"], ctx)).stdout.split("\n").map((l) => l.trim())
|
|
387
|
+
for (const head of [work.stdout.trim(), ...heads].filter(Boolean)) {
|
|
388
|
+
if ((await this.gitOutput(host.directory, ["cat-file", "-e", `${head}^{commit}`], ctx)) === undefined) return "the sandbox has commits that were not collected"
|
|
389
|
+
}
|
|
390
|
+
return undefined
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async isDirtyInside(name: string, ctx: RuntimeContext): Promise<boolean> {
|
|
394
|
+
return (await this.mustExec(name, ["git", "-C", "work", "status", "--porcelain", "--untracked-files=all"], ctx)).stdout.trim().length > 0
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Polls until the sandbox is gone (OpenShell deletes asynchronously). */
|
|
398
|
+
private async waitGone(name: string, ctx: RuntimeContext): Promise<void> {
|
|
399
|
+
const deadline = Date.now() + this.readyTimeoutMs
|
|
400
|
+
while (await this.call(() => this.client.get(name, this.signal(ctx)))) {
|
|
401
|
+
if (ctx.signal?.aborted) throw new RuntimeError("cleanup cancelled")
|
|
402
|
+
if (Date.now() > deadline) throw new RuntimeError(`sandbox ${name} was not deleted after ${Math.round(this.readyTimeoutMs / 1000)} s`)
|
|
403
|
+
await sleep(this.pollMs)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------- helpers ----------
|
|
408
|
+
|
|
409
|
+
private signal(ctx: RuntimeContext): { signal?: AbortSignal } {
|
|
410
|
+
return ctx.signal ? { signal: ctx.signal } : {}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Runs an OpenShell call; its OpenShellError (already redacted, and without `hide`) becomes a RuntimeError. */
|
|
414
|
+
private async call<T>(fn: () => Promise<T>, hide: readonly string[] = []): Promise<T> {
|
|
415
|
+
try {
|
|
416
|
+
return await fn()
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (error instanceof OpenShellError) throw new RuntimeError(redact(error.message, hide))
|
|
419
|
+
throw error
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** A command in the sandbox, in its home directory (no shell: argv as given). */
|
|
424
|
+
private exec(name: string, command: readonly string[], ctx: RuntimeContext, hide: readonly string[] = []): Promise<ExecResult> {
|
|
425
|
+
return this.call(() => this.client.exec(name, command, { workdir: SANDBOX_HOME, ...this.signal(ctx) }), hide)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async mustExec(name: string, command: readonly string[], ctx: RuntimeContext, hide: readonly string[] = []): Promise<ExecResult> {
|
|
429
|
+
const result = await this.exec(name, command, ctx, hide)
|
|
430
|
+
if (result.exitCode !== 0) {
|
|
431
|
+
const why = redact(result.stderr.trim(), hide) || "no error output"
|
|
432
|
+
throw new RuntimeError(cut(`in sandbox ${name}: ${command.slice(0, 2).join(" ")} failed (exit ${result.exitCode}): ${why}`))
|
|
433
|
+
}
|
|
434
|
+
return result
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private async withTemp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
438
|
+
const dir = mkdtempSync(path.join(tmpdir(), "hub-openshell-"))
|
|
439
|
+
try {
|
|
440
|
+
return await fn(dir)
|
|
441
|
+
} finally {
|
|
442
|
+
rmSync(dir, { recursive: true, force: true })
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private async git(cwd: string, args: readonly string[], ctx: RuntimeContext, env?: Readonly<Record<string, string>>): Promise<string> {
|
|
447
|
+
const options = this.options.git ?? {}
|
|
448
|
+
try {
|
|
449
|
+
return await runGit(args, { ...options, cwd, ...(env ? { env: { ...options.env, ...env } } : {}), ...this.signal(ctx) })
|
|
450
|
+
} catch (cause) {
|
|
451
|
+
if (cause instanceof GitError) throw new RuntimeError(cause.message)
|
|
452
|
+
throw cause
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async gitOutput(cwd: string, args: readonly string[], ctx: RuntimeContext): Promise<string | undefined> {
|
|
457
|
+
try {
|
|
458
|
+
return await this.git(cwd, args, ctx)
|
|
459
|
+
} catch (cause) {
|
|
460
|
+
if (ctx.signal?.aborted) throw cause
|
|
461
|
+
return undefined
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The launch that made a host clone (HostRuntime's marker), or undefined. */
|
|
466
|
+
private async marker(dir: string, ctx: RuntimeContext): Promise<string | undefined> {
|
|
467
|
+
const gitDir = (await this.gitOutput(dir, ["rev-parse", "--absolute-git-dir"], ctx))?.trim()
|
|
468
|
+
if (!gitDir) return undefined
|
|
469
|
+
try {
|
|
470
|
+
return readFileSync(path.join(gitDir, LAUNCH_MARKER), "utf8").trim()
|
|
471
|
+
} catch {
|
|
472
|
+
return undefined
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-runtime-sdk",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The Runtime interface (design §6.4): what `hub node` drives for each launch, prepare → (start) → collect →
|
|
2
|
+
// cleanup. A runtime keeps no state of its own between ops: prepare returns the Workspace and collect and cleanup
|
|
3
|
+
// get it back with the launch ID (a RunHandle; the hub stores it), so a restarted node can still collect and clean
|
|
4
|
+
// up what an earlier run prepared. A runtime must not trust a handle: it re-checks the workspace on every op.
|
|
5
|
+
//
|
|
6
|
+
// The `host` runtime has no start/stop: the session runs in the machine's long-running harness service, which the
|
|
7
|
+
// hub asks to create it. Sandbox runtimes (openshell) will implement start/stop.
|
|
8
|
+
import type {
|
|
9
|
+
CleanupPolicy,
|
|
10
|
+
LaunchArtifacts,
|
|
11
|
+
LaunchRuntime,
|
|
12
|
+
LaunchWorkspace,
|
|
13
|
+
NodeRuntimeInfo,
|
|
14
|
+
PreparedWorkspace,
|
|
15
|
+
} from "../../protocol/src/index.ts"
|
|
16
|
+
|
|
17
|
+
/** A prepared workspace: the session's directory and how it was made. */
|
|
18
|
+
export type Workspace = PreparedWorkspace
|
|
19
|
+
/** What collect found in a workspace (branch, head, commits since base, dirty). */
|
|
20
|
+
export type Artifacts = LaunchArtifacts
|
|
21
|
+
|
|
22
|
+
/** What prepare gets: the launch, its harness and runtime, and the workspace it asked for. */
|
|
23
|
+
export interface PrepareInput {
|
|
24
|
+
readonly launchID: string
|
|
25
|
+
readonly harness: string
|
|
26
|
+
readonly runtime: LaunchRuntime
|
|
27
|
+
readonly workspace: LaunchWorkspace
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What start gets: the launch, its harness, how its spoke connects back to the hub, and extra environment. */
|
|
31
|
+
export interface StartInput {
|
|
32
|
+
readonly launchID: string
|
|
33
|
+
readonly harness: string
|
|
34
|
+
/** The hub URL as the node knows it, and a token bound to this launch (secret: never in errors or argv). */
|
|
35
|
+
readonly hub: { readonly url: string; readonly token: string }
|
|
36
|
+
/** Extra non-secret environment for the harness (upper-case names). */
|
|
37
|
+
readonly env?: Readonly<Record<string, string>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Per-op context: cancellation, and a short human progress line ("cloning …"). */
|
|
41
|
+
export interface RuntimeContext {
|
|
42
|
+
readonly signal?: AbortSignal
|
|
43
|
+
readonly progress?: (message: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A launch's prepared (or started) workspace, as collect, cleanup and stop get it. */
|
|
47
|
+
export interface RunHandle {
|
|
48
|
+
readonly launchID: string
|
|
49
|
+
readonly workspace: Workspace
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `removed: false` with a reason when the workspace was kept (policy keep, reused, uncommitted work). */
|
|
53
|
+
export interface CleanupResult {
|
|
54
|
+
readonly removed: boolean
|
|
55
|
+
readonly reason?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What start did: `started: false` with a reason when the harness had been started already (a repeated start). */
|
|
59
|
+
export interface StartResult extends RunHandle {
|
|
60
|
+
readonly started: boolean
|
|
61
|
+
readonly reason?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What stop did: `stopped: false` with a reason when nothing was running. */
|
|
65
|
+
export interface StopResult {
|
|
66
|
+
readonly stopped: boolean
|
|
67
|
+
readonly reason?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type StopMode = "graceful" | "kill"
|
|
71
|
+
|
|
72
|
+
export interface Runtime {
|
|
73
|
+
readonly kind: string
|
|
74
|
+
/** What the node's hello reports for this runtime (kind, harnesses, profiles, backends). */
|
|
75
|
+
info(): NodeRuntimeInfo
|
|
76
|
+
prepare(input: PrepareInput, ctx?: RuntimeContext): Promise<Workspace>
|
|
77
|
+
start?(workspace: Workspace, input: StartInput, ctx?: RuntimeContext): Promise<StartResult>
|
|
78
|
+
stop?(handle: RunHandle, mode: StopMode): Promise<StopResult>
|
|
79
|
+
collect(handle: RunHandle, ctx?: RuntimeContext): Promise<Artifacts>
|
|
80
|
+
/** Keep, or remove without force: uncommitted or unpushed work, a reused workspace or another launch's keeps it. */
|
|
81
|
+
cleanup(handle: RunHandle, policy: CleanupPolicy, ctx?: RuntimeContext): Promise<CleanupResult>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A runtime error with a message fit for the hub (and the launch's reason): no stack, no secrets. */
|
|
85
|
+
export class RuntimeError extends Error {
|
|
86
|
+
override readonly name = "RuntimeError"
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-sdk",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @name / @@role addressing (design §2.1): name and role syntax and address parsing. Pure; shared by the hub
|
|
2
|
+
// (src/central/agent/names.ts re-exports it) and the plugin's prompt-reference hook.
|
|
3
|
+
|
|
4
|
+
/** Names and roles: lowercase letters, digits and dashes, 2–40 characters. */
|
|
5
|
+
export const NAME_MAX = 40
|
|
6
|
+
const NAME = /^[a-z0-9-]{2,40}$/
|
|
7
|
+
|
|
8
|
+
export const validName = (name: string): boolean => NAME.test(name)
|
|
9
|
+
|
|
10
|
+
export type Address = { kind: "id"; id: string } | { kind: "name"; name: string; project?: string } | { kind: "role"; role: string; project?: string }
|
|
11
|
+
|
|
12
|
+
/** A project scope: any non-empty run of characters without dots, slashes or spaces, compared lowercased. */
|
|
13
|
+
const SCOPED = /^([a-z0-9-]{2,40})(?:\.([^./\s]+))?$/i
|
|
14
|
+
|
|
15
|
+
/** `ses_…` (an ID or ID prefix), `@name[.project]` or `@@role[.project]`; undefined when malformed. */
|
|
16
|
+
export function parseAddress(addr: string): Address | undefined {
|
|
17
|
+
if (addr.startsWith("ses_")) return /^ses_[A-Za-z0-9]+$/.test(addr) ? { kind: "id", id: addr } : undefined
|
|
18
|
+
const role = addr.startsWith("@@")
|
|
19
|
+
if (!role && !addr.startsWith("@")) return undefined
|
|
20
|
+
const match = SCOPED.exec(addr.slice(role ? 2 : 1))
|
|
21
|
+
if (!match || !validName(match[1]!)) return undefined
|
|
22
|
+
const project = match[2]?.toLowerCase()
|
|
23
|
+
const scope = project ? { project } : {}
|
|
24
|
+
return role ? { kind: "role", role: match[1]!, ...scope } : { kind: "name", name: match[1]!, ...scope }
|
|
25
|
+
}
|