@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,172 @@
|
|
|
1
|
+
// Launch vocabulary (design §6.1, §6.4), shared by the launch API, the hub's launch engine, `hub node` and the
|
|
2
|
+
// spoke. Wire-level checks only: the launch API's own rules (idempotency, grants, placement) live in the hub.
|
|
3
|
+
//
|
|
4
|
+
// Git hardening starts here: refs that `git check-ref-format` would accept and that never read as an option,
|
|
5
|
+
// https/ssh remotes only, absolute normal repo paths. Nodes and the hub still run git with execFile argv and `--`.
|
|
6
|
+
import { isObj, UNSAFE_CHARS } from "./wire.ts"
|
|
7
|
+
|
|
8
|
+
/** A launch's state; each change carries a reason. */
|
|
9
|
+
export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
|
|
10
|
+
export type LaunchState = (typeof LAUNCH_STATES)[number]
|
|
11
|
+
|
|
12
|
+
/** How an ended launch ended. */
|
|
13
|
+
export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
|
|
14
|
+
export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
|
|
15
|
+
|
|
16
|
+
/** `worktree`: `<repo>/.worktrees/<branch>` (reused when present); `checkout`: the repo itself; `clone`: a fresh clone at `repo`. */
|
|
17
|
+
export const WORKSPACE_MODES = ["worktree", "checkout", "clone"] as const
|
|
18
|
+
export type WorkspaceMode = (typeof WORKSPACE_MODES)[number]
|
|
19
|
+
|
|
20
|
+
export const RUNTIME_KINDS = ["host", "openshell"] as const
|
|
21
|
+
export type RuntimeKind = (typeof RUNTIME_KINDS)[number]
|
|
22
|
+
|
|
23
|
+
export const PLACEMENT_MODES = ["require", "prefer"] as const
|
|
24
|
+
export type PlacementMode = (typeof PLACEMENT_MODES)[number]
|
|
25
|
+
|
|
26
|
+
/** `idle`: idle by real evidence with nothing open for 30 s; `asked`: ended with a question open (needs_user); `never`: ended by the app or user. */
|
|
27
|
+
export const LAUNCH_END_WHEN = ["idle", "asked", "never"] as const
|
|
28
|
+
export type LaunchEndWhen = (typeof LAUNCH_END_WHEN)[number]
|
|
29
|
+
|
|
30
|
+
export const CLEANUP_POLICIES = ["keep", "remove"] as const
|
|
31
|
+
export type CleanupPolicy = (typeof CLEANUP_POLICIES)[number]
|
|
32
|
+
|
|
33
|
+
export interface LaunchWorkspace {
|
|
34
|
+
/** Absolute path: the repository (worktree, checkout) or where the clone goes (clone). */
|
|
35
|
+
readonly repo: string
|
|
36
|
+
readonly mode: WorkspaceMode
|
|
37
|
+
readonly branch?: string
|
|
38
|
+
readonly base?: string
|
|
39
|
+
/** Required for clone: an https or ssh remote. */
|
|
40
|
+
readonly remote?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LaunchRuntime {
|
|
44
|
+
readonly kind: RuntimeKind
|
|
45
|
+
readonly profile?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LaunchPlacement {
|
|
49
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
50
|
+
/** A machine name or ID. */
|
|
51
|
+
readonly machine?: string
|
|
52
|
+
readonly mode?: PlacementMode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LaunchEnd {
|
|
56
|
+
readonly when?: LaunchEndWhen
|
|
57
|
+
/** A duration such as "4h" or "90m". */
|
|
58
|
+
readonly timeout?: string
|
|
59
|
+
readonly cleanup?: CleanupPolicy
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A launch request body (POST /api/agent/launches). Phase 4 has no `grant` field: a launch gets no powers beyond its launcher's. */
|
|
63
|
+
export interface LaunchSpec {
|
|
64
|
+
/** Idempotency key: the same key and body return the same launch. */
|
|
65
|
+
readonly key?: string
|
|
66
|
+
readonly name?: string
|
|
67
|
+
readonly roles?: readonly string[]
|
|
68
|
+
readonly harness?: string
|
|
69
|
+
readonly agent?: string
|
|
70
|
+
/** "provider/model" as the launcher wrote it. */
|
|
71
|
+
readonly model?: string
|
|
72
|
+
readonly workspace: LaunchWorkspace
|
|
73
|
+
readonly runtime?: LaunchRuntime
|
|
74
|
+
readonly placement?: LaunchPlacement
|
|
75
|
+
readonly prompt?: string
|
|
76
|
+
/** Free-form; never interpreted by the hub. */
|
|
77
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
78
|
+
readonly end?: LaunchEnd
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a node collected from a launch's workspace. */
|
|
82
|
+
export interface LaunchArtifacts {
|
|
83
|
+
readonly branch?: string
|
|
84
|
+
readonly base?: string
|
|
85
|
+
/** The workspace's HEAD commit. */
|
|
86
|
+
readonly head?: string
|
|
87
|
+
/** Commits on `branch` since `base`. */
|
|
88
|
+
readonly commits?: number
|
|
89
|
+
/** Uncommitted changes were left in the workspace. */
|
|
90
|
+
readonly dirty?: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A launch as the API reports it. */
|
|
94
|
+
export interface LaunchInfo {
|
|
95
|
+
readonly id: string
|
|
96
|
+
readonly state: LaunchState
|
|
97
|
+
readonly reason: string
|
|
98
|
+
readonly spec: LaunchSpec
|
|
99
|
+
/** ms epoch */
|
|
100
|
+
readonly created: number
|
|
101
|
+
readonly updated: number
|
|
102
|
+
readonly machineID?: string
|
|
103
|
+
readonly directory?: string
|
|
104
|
+
readonly sessionID?: string
|
|
105
|
+
readonly name?: string
|
|
106
|
+
readonly outcome?: LaunchOutcome
|
|
107
|
+
readonly artifacts?: LaunchArtifacts
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- checks ----------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
export const PATH_MAX = 4096
|
|
114
|
+
export const REF_MAX = 200
|
|
115
|
+
export const REMOTE_MAX = 2048
|
|
116
|
+
export const LABELS_MAX = 32
|
|
117
|
+
export const LABEL_VALUE_MAX = 256
|
|
118
|
+
const LABEL_KEY = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,62}$/
|
|
119
|
+
|
|
120
|
+
/** An absolute path of at most PATH_MAX characters without `.`/`..` segments, empty segments or unsafe characters. */
|
|
121
|
+
export function isAbsolutePath(v: unknown): v is string {
|
|
122
|
+
if (typeof v !== "string" || !v.startsWith("/") || v.length > PATH_MAX || UNSAFE_CHARS.test(v)) return false
|
|
123
|
+
if (v === "/") return true
|
|
124
|
+
return v
|
|
125
|
+
.slice(1)
|
|
126
|
+
.replace(/\/$/, "")
|
|
127
|
+
.split("/")
|
|
128
|
+
.every((part) => part !== "" && part !== "." && part !== "..")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A branch name `git check-ref-format --branch` accepts, from a conservative character set, never starting with `-`. */
|
|
132
|
+
export function isRefName(v: unknown): v is string {
|
|
133
|
+
if (typeof v !== "string" || v.length === 0 || v.length > REF_MAX) return false
|
|
134
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(v) || v.startsWith("-") || v.endsWith(".") || v.includes("..")) return false
|
|
135
|
+
return v.split("/").every((part) => part !== "" && !part.startsWith(".") && !part.endsWith(".lock"))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** An https or ssh URL, or scp-style `user@host:path`, of at most REMOTE_MAX characters; never an option. */
|
|
139
|
+
export function isGitRemote(v: unknown): v is string {
|
|
140
|
+
if (typeof v !== "string" || v.length === 0 || v.length > REMOTE_MAX || v.startsWith("-") || /[\s\p{Cc}\p{Cf}]/u.test(v)) return false
|
|
141
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[^-:][^:]*$/.test(v)) return true
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(v)
|
|
144
|
+
return (url.protocol === "https:" || url.protocol === "ssh:") && url.hostname.length > 0 && !url.hostname.startsWith("-") && url.pathname.length > 1
|
|
145
|
+
} catch {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Why `v` is not an acceptable launch workspace, or undefined when it is. */
|
|
151
|
+
export function launchWorkspaceProblem(v: unknown): string | undefined {
|
|
152
|
+
if (!isObj(v)) return "workspace must be an object"
|
|
153
|
+
if (!isAbsolutePath(v.repo)) return `workspace.repo must be an absolute path of at most ${PATH_MAX} characters without . or .. segments`
|
|
154
|
+
if (!(WORKSPACE_MODES as readonly unknown[]).includes(v.mode)) return `workspace.mode must be one of ${WORKSPACE_MODES.join(", ")}`
|
|
155
|
+
if (v.branch !== undefined && !isRefName(v.branch)) return "workspace.branch must be a valid branch name (letters, digits, . _ / -; not starting with -)"
|
|
156
|
+
if (v.base !== undefined && !isRefName(v.base)) return "workspace.base must be a valid branch name (letters, digits, . _ / -; not starting with -)"
|
|
157
|
+
if (v.remote !== undefined && !isGitRemote(v.remote)) return "workspace.remote must be an https or ssh git remote"
|
|
158
|
+
if (v.mode === "clone" && v.remote === undefined) return "workspace.remote is required for a clone"
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Why `v` is not an acceptable label map, or undefined when it is. */
|
|
163
|
+
export function labelsProblem(v: unknown): string | undefined {
|
|
164
|
+
if (!isObj(v)) return "labels must be an object"
|
|
165
|
+
const entries = Object.entries(v)
|
|
166
|
+
if (entries.length > LABELS_MAX) return `at most ${LABELS_MAX} labels`
|
|
167
|
+
for (const [key, value] of entries) {
|
|
168
|
+
if (!LABEL_KEY.test(key)) return `label key ${JSON.stringify(key.slice(0, 80))} must be 1–63 letters, digits, . _ / - (starting with a letter or digit)`
|
|
169
|
+
if (typeof value !== "string" || value.length > LABEL_VALUE_MAX || UNSAFE_CHARS.test(value)) return `label ${key} must be text of at most ${LABEL_VALUE_MAX} printable characters`
|
|
170
|
+
}
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Hub ↔ node protocol (design §6.3). `hub node` is a daemon per machine: it connects out to /spoke/ws with the
|
|
2
|
+
// machine's token (spoke and node share one machine record) and a hello with role "node", never a spoke hello, so
|
|
3
|
+
// a node never appears as a process. It reports runtimes, labels, capacity and the launches it carries, and serves
|
|
4
|
+
// the hub's prepare → collect → cleanup ops (plus cancel) for each launch; with the "sandbox" feature also start and
|
|
5
|
+
// stop (it runs the launch's harness itself, e.g. in an OpenShell sandbox, whose spoke connects back with a launch
|
|
6
|
+
// token). It reports only launch progress and capacity; session state always comes from the session's own spoke.
|
|
7
|
+
// There is no remote exec channel.
|
|
8
|
+
//
|
|
9
|
+
// Compatibility: a hub that predates nodes rejects the node hello (it lacks the spoke hello fields) and so never
|
|
10
|
+
// sees a node frame. A node treats a welcome without the "nodes" feature (hubServesNodes) as an old hub and backs
|
|
11
|
+
// off. Frames share the spoke protocol's `t` discriminator, `req`/`res` envelope, MAX_FRAME and `bye`.
|
|
12
|
+
import type { Bye, Req, Res, Welcome } from "./index.ts"
|
|
13
|
+
import {
|
|
14
|
+
isAbsolutePath,
|
|
15
|
+
labelsProblem,
|
|
16
|
+
launchWorkspaceProblem,
|
|
17
|
+
isRefName,
|
|
18
|
+
CLEANUP_POLICIES,
|
|
19
|
+
RUNTIME_KINDS,
|
|
20
|
+
WORKSPACE_MODES,
|
|
21
|
+
type CleanupPolicy,
|
|
22
|
+
type LaunchArtifacts,
|
|
23
|
+
type LaunchRuntime,
|
|
24
|
+
type LaunchWorkspace,
|
|
25
|
+
type RuntimeKind,
|
|
26
|
+
type WorkspaceMode,
|
|
27
|
+
} from "./launch.ts"
|
|
28
|
+
import { featureList, ID, isByeFrame, isHubURL, isObj, isResFrame, isWelcomeFrame, nonEmpty, num, parseFrame, str, token, UNSAFE_CHARS } from "./wire.ts"
|
|
29
|
+
|
|
30
|
+
export const NODE_ROLE = "node"
|
|
31
|
+
/** The role a hello announces: "node", or "spoke" (a hello without `role`, or with role "spoke"). */
|
|
32
|
+
export type HelloRole = typeof NODE_ROLE | "spoke"
|
|
33
|
+
|
|
34
|
+
/** A runtime the node offers: its kind, and the harnesses, profiles or backends it has. */
|
|
35
|
+
export interface NodeRuntimeInfo {
|
|
36
|
+
readonly kind: string
|
|
37
|
+
readonly harnesses?: readonly string[]
|
|
38
|
+
readonly profiles?: readonly string[]
|
|
39
|
+
readonly backends?: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Slots (concurrent launches) and memory. */
|
|
43
|
+
export interface NodeCapacity {
|
|
44
|
+
readonly slots: number
|
|
45
|
+
readonly memoryMB?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where the node is with one launch (starting … stopped: a harness the node runs, e.g. in a sandbox; see "sandbox"). */
|
|
49
|
+
export const NODE_LAUNCH_PHASES = ["preparing", "prepared", "starting", "running", "stopping", "stopped", "collecting", "collected", "cleaning", "cleaned", "cancelled", "failed"] as const
|
|
50
|
+
export type NodeLaunchPhase = (typeof NODE_LAUNCH_PHASES)[number]
|
|
51
|
+
|
|
52
|
+
/** A launch the node carries (in its hello, so a reconnecting node tells the hub what survived). */
|
|
53
|
+
export interface NodeLaunchReport {
|
|
54
|
+
readonly launchID: string
|
|
55
|
+
readonly phase: NodeLaunchPhase
|
|
56
|
+
readonly directory?: string
|
|
57
|
+
/** The launch's OpenShell sandbox, when it has one. */
|
|
58
|
+
readonly sandbox?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Optional node features (announced in the node hello). "sandbox": the node serves start and stop (it runs a
|
|
63
|
+
* launch's harness itself, e.g. in an OpenShell sandbox) and reports prepared workspaces with their runtime.
|
|
64
|
+
*/
|
|
65
|
+
export const KNOWN_NODE_FEATURES = ["sandbox"] as const
|
|
66
|
+
export type NodeFeature = (typeof KNOWN_NODE_FEATURES)[number]
|
|
67
|
+
/** What this package's `hub node` can announce; "sandbox" only while it offers a runtime that starts harnesses
|
|
68
|
+
* (node.json `openshell.enabled`). */
|
|
69
|
+
export const NODE_FEATURES: readonly NodeFeature[] = ["sandbox"]
|
|
70
|
+
|
|
71
|
+
/** The features a node announced in its hello. */
|
|
72
|
+
export function nodeFeatures(hello: Pick<NodeHello, "features">): ReadonlySet<string> {
|
|
73
|
+
return new Set(hello.features ?? [])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- node → hub ----------
|
|
77
|
+
|
|
78
|
+
export interface NodeHello {
|
|
79
|
+
readonly t: "hello"
|
|
80
|
+
readonly v: number
|
|
81
|
+
readonly role: typeof NODE_ROLE
|
|
82
|
+
/** This daemon run (a new one per start), so the hub can tell a reconnect from a second node. */
|
|
83
|
+
readonly instanceID: string
|
|
84
|
+
readonly nodeVersion: string
|
|
85
|
+
/** Optional node features (see KNOWN_NODE_FEATURES); phase 4 nodes send none. */
|
|
86
|
+
readonly features?: readonly string[]
|
|
87
|
+
readonly runtimes: readonly NodeRuntimeInfo[]
|
|
88
|
+
readonly labels: Readonly<Record<string, string>>
|
|
89
|
+
readonly capacity: NodeCapacity
|
|
90
|
+
readonly used: NodeCapacity
|
|
91
|
+
/** Workspaces must be under one of these (node.json roots). */
|
|
92
|
+
readonly workspaceRoots: readonly string[]
|
|
93
|
+
/** A draining node takes no new launches. */
|
|
94
|
+
readonly draining: boolean
|
|
95
|
+
readonly launches: readonly NodeLaunchReport[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The node's capacity, use, draining flag and (when given) labels now; replaces what its hello said. */
|
|
99
|
+
export interface NodeCapacityFrame {
|
|
100
|
+
readonly t: "capacity"
|
|
101
|
+
readonly capacity: NodeCapacity
|
|
102
|
+
readonly used: NodeCapacity
|
|
103
|
+
readonly draining: boolean
|
|
104
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A launch's progress on the node: its phase and a short human message (e.g. "cloning …"). */
|
|
108
|
+
export interface NodeProgress {
|
|
109
|
+
readonly t: "progress"
|
|
110
|
+
readonly launchID: string
|
|
111
|
+
readonly phase: NodeLaunchPhase
|
|
112
|
+
readonly message?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type NodeMessage = NodeHello | NodeCapacityFrame | NodeProgress | Res | Bye
|
|
116
|
+
|
|
117
|
+
// ---------- hub → node ----------
|
|
118
|
+
|
|
119
|
+
/** Where a prepared workspace lives: on the host, or in an OpenShell sandbox (named by the node at prepare). */
|
|
120
|
+
export interface PreparedRuntime {
|
|
121
|
+
readonly kind: RuntimeKind
|
|
122
|
+
/** The OpenShell sandbox (a lowercase DNS label, see isSandboxName); only for kind "openshell". */
|
|
123
|
+
readonly sandbox?: string
|
|
124
|
+
/** The hub profile the sandbox was prepared with. */
|
|
125
|
+
readonly profile?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A workspace the node prepared; collect and cleanup carry it back, so the node needs no state of its own. */
|
|
129
|
+
export interface PreparedWorkspace {
|
|
130
|
+
readonly repo: string
|
|
131
|
+
/** The session's directory: the worktree, the checkout or the clone. */
|
|
132
|
+
readonly directory: string
|
|
133
|
+
readonly mode: WorkspaceMode
|
|
134
|
+
readonly branch?: string
|
|
135
|
+
readonly base?: string
|
|
136
|
+
readonly head?: string
|
|
137
|
+
/** The worktree or clone existed before this launch; cleanup never removes a reused workspace. */
|
|
138
|
+
readonly reused: boolean
|
|
139
|
+
/** Where the workspace lives; absent (phase 4 nodes) means the host. */
|
|
140
|
+
readonly runtime?: PreparedRuntime
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A prepared workspace's runtime: its `runtime`, or the host when it has none. */
|
|
144
|
+
export function preparedWorkspaceRuntime(workspace: PreparedWorkspace): PreparedRuntime {
|
|
145
|
+
return workspace.runtime ?? { kind: "host" }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** OpenShell sandbox names are lowercase DNS labels: 1–63 letters, digits and `-`, not starting or ending with `-`. */
|
|
149
|
+
const SANDBOX_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
150
|
+
|
|
151
|
+
/** Whether `v` is an acceptable OpenShell sandbox name. */
|
|
152
|
+
export function isSandboxName(v: unknown): v is string {
|
|
153
|
+
return typeof v === "string" && SANDBOX_NAME.test(v)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type NodeRequest =
|
|
157
|
+
/** Prepare the launch's workspace (worktree, checkout or clone) under the node's roots. */
|
|
158
|
+
| { readonly op: "prepare"; readonly launchID: string; readonly runtime: LaunchRuntime; readonly harness: string; readonly workspace: LaunchWorkspace }
|
|
159
|
+
/** Collect artifacts (branch, head, commits since base, dirty) from a prepared workspace. */
|
|
160
|
+
| { readonly op: "collect"; readonly launchID: string; readonly workspace: PreparedWorkspace }
|
|
161
|
+
/** Keep or remove a prepared workspace; removal never forces (uncommitted work keeps it, with a reason). */
|
|
162
|
+
| { readonly op: "cleanup"; readonly launchID: string; readonly workspace: PreparedWorkspace; readonly policy: CleanupPolicy }
|
|
163
|
+
/** Stop whatever the node is doing for the launch (a running prepare). */
|
|
164
|
+
| { readonly op: "cancel"; readonly launchID: string }
|
|
165
|
+
/** Start the launch's harness in its prepared workspace (feature "sandbox"); it connects back to `hub`. */
|
|
166
|
+
| StartRequest
|
|
167
|
+
/** Stop the launch's harness (feature "sandbox"); `graceful` (the default) lets it finish its turn first. */
|
|
168
|
+
| { readonly op: "stop"; readonly launchID: string; readonly workspace: PreparedWorkspace; readonly mode?: StopMode }
|
|
169
|
+
|
|
170
|
+
export type NodeOp = NodeRequest["op"]
|
|
171
|
+
|
|
172
|
+
export const STOP_MODES = ["graceful", "kill"] as const
|
|
173
|
+
export type StopMode = (typeof STOP_MODES)[number]
|
|
174
|
+
|
|
175
|
+
/** Most environment variables a start op sets. */
|
|
176
|
+
export const START_ENV_MAX = 64
|
|
177
|
+
/** Longest environment value, and longest launch token, a start op carries. */
|
|
178
|
+
export const START_VALUE_MAX = 4096
|
|
179
|
+
|
|
180
|
+
export interface StartRequest {
|
|
181
|
+
readonly op: "start"
|
|
182
|
+
readonly launchID: string
|
|
183
|
+
readonly workspace: PreparedWorkspace
|
|
184
|
+
readonly harness: string
|
|
185
|
+
/** Where and how the harness's spoke connects back: the hub URL it can reach and a token bound to this launch. */
|
|
186
|
+
readonly hub: { readonly url: string; readonly token: string }
|
|
187
|
+
/** Extra environment for the harness (upper-case names); never secrets the node holds itself. */
|
|
188
|
+
readonly env?: Readonly<Record<string, string>>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The node feature a request needs, or undefined for the phase 4 ops every node serves. */
|
|
192
|
+
export function nodeRequestFeature(req: Pick<NodeRequest, "op">): NodeFeature | undefined {
|
|
193
|
+
return req.op === "start" || req.op === "stop" ? "sandbox" : undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Result payloads, keyed by node op. */
|
|
197
|
+
export interface NodeResultFor {
|
|
198
|
+
prepare: { readonly workspace: PreparedWorkspace }
|
|
199
|
+
collect: { readonly artifacts: LaunchArtifacts }
|
|
200
|
+
/** `removed: false` with a `reason` when the workspace was kept (policy keep, reused, uncommitted work). */
|
|
201
|
+
cleanup: { readonly removed: boolean; readonly reason?: string }
|
|
202
|
+
/** `cancelled: false` when the node had nothing running for the launch. */
|
|
203
|
+
cancel: { readonly cancelled: boolean }
|
|
204
|
+
/** `started: false` with a `reason` when the harness was already running (a repeated start). */
|
|
205
|
+
start: { readonly started: boolean; readonly reason?: string }
|
|
206
|
+
/** `stopped: false` with a `reason` when nothing was running for the launch. */
|
|
207
|
+
stop: { readonly stopped: boolean; readonly reason?: string }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type NodeServerMessage = Welcome | Req<NodeRequest> | Res | Bye
|
|
211
|
+
|
|
212
|
+
/** Whether a hub's welcome announces nodes (an older hub never does; a node then backs off). */
|
|
213
|
+
export function hubServesNodes(welcome: Welcome): boolean {
|
|
214
|
+
return welcome.features?.includes("nodes") === true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------- validation ----------
|
|
218
|
+
|
|
219
|
+
/** Longest progress message or cleanup reason. */
|
|
220
|
+
export const NODE_MESSAGE_MAX = 500
|
|
221
|
+
export const NODE_RUNTIMES_MAX = 8
|
|
222
|
+
export const NODE_ROOTS_MAX = 32
|
|
223
|
+
export const NODE_LAUNCHES_MAX = 1000
|
|
224
|
+
export const NODE_SLOTS_MAX = 10_000
|
|
225
|
+
const LIST_MAX = 32
|
|
226
|
+
|
|
227
|
+
const launchID = (v: unknown): v is string => str(v) && ID.test(v)
|
|
228
|
+
const whole = (v: unknown, max: number): v is number => num(v) && Number.isInteger(v) && v >= 0 && v <= max
|
|
229
|
+
const tokenList = (v: unknown): boolean => Array.isArray(v) && v.length <= LIST_MAX && v.every((t) => token(t, 64))
|
|
230
|
+
const message = (v: unknown): v is string => nonEmpty(v) && v.length <= NODE_MESSAGE_MAX && !UNSAFE_CHARS.test(v)
|
|
231
|
+
const phase = (v: unknown): v is NodeLaunchPhase => (NODE_LAUNCH_PHASES as readonly unknown[]).includes(v)
|
|
232
|
+
const COMMIT = /^[0-9a-f]{7,64}$/
|
|
233
|
+
|
|
234
|
+
const isCapacity = (v: unknown): v is NodeCapacity => isObj(v) && whole(v.slots, NODE_SLOTS_MAX) && (v.memoryMB === undefined || whole(v.memoryMB, 2 ** 31))
|
|
235
|
+
|
|
236
|
+
const isRuntimeInfo = (v: unknown): v is NodeRuntimeInfo =>
|
|
237
|
+
isObj(v) &&
|
|
238
|
+
token(v.kind, 64) &&
|
|
239
|
+
(v.harnesses === undefined || tokenList(v.harnesses)) &&
|
|
240
|
+
(v.profiles === undefined || tokenList(v.profiles)) &&
|
|
241
|
+
(v.backends === undefined || tokenList(v.backends))
|
|
242
|
+
|
|
243
|
+
const isLaunchReport = (v: unknown): v is NodeLaunchReport =>
|
|
244
|
+
isObj(v) && launchID(v.launchID) && phase(v.phase) && (v.directory === undefined || isAbsolutePath(v.directory)) && (v.sandbox === undefined || isSandboxName(v.sandbox))
|
|
245
|
+
|
|
246
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/
|
|
247
|
+
const TOKEN_TEXT = new RegExp(`^[\\x21-\\x7e]{1,${START_VALUE_MAX}}$`)
|
|
248
|
+
|
|
249
|
+
const isStartEnv = (v: unknown): boolean =>
|
|
250
|
+
isObj(v) &&
|
|
251
|
+
Object.keys(v).length <= START_ENV_MAX &&
|
|
252
|
+
Object.entries(v).every(([name, value]) => ENV_NAME.test(name) && str(value) && value.length <= START_VALUE_MAX && !UNSAFE_CHARS.test(value))
|
|
253
|
+
|
|
254
|
+
const isHubConnection = (v: unknown): boolean => isObj(v) && isHubURL(v.url) && str(v.token) && TOKEN_TEXT.test(v.token)
|
|
255
|
+
|
|
256
|
+
function isNodeHello(m: Record<string, unknown>): boolean {
|
|
257
|
+
return (
|
|
258
|
+
m.role === NODE_ROLE &&
|
|
259
|
+
num(m.v) &&
|
|
260
|
+
launchID(m.instanceID) &&
|
|
261
|
+
str(m.nodeVersion) &&
|
|
262
|
+
m.nodeVersion.length <= 100 &&
|
|
263
|
+
(m.features === undefined || featureList(m.features)) &&
|
|
264
|
+
Array.isArray(m.runtimes) &&
|
|
265
|
+
m.runtimes.length <= NODE_RUNTIMES_MAX &&
|
|
266
|
+
m.runtimes.every(isRuntimeInfo) &&
|
|
267
|
+
labelsProblem(m.labels) === undefined &&
|
|
268
|
+
isCapacity(m.capacity) &&
|
|
269
|
+
isCapacity(m.used) &&
|
|
270
|
+
Array.isArray(m.workspaceRoots) &&
|
|
271
|
+
m.workspaceRoots.length <= NODE_ROOTS_MAX &&
|
|
272
|
+
m.workspaceRoots.every(isAbsolutePath) &&
|
|
273
|
+
typeof m.draining === "boolean" &&
|
|
274
|
+
Array.isArray(m.launches) &&
|
|
275
|
+
m.launches.length <= NODE_LAUNCHES_MAX &&
|
|
276
|
+
m.launches.every(isLaunchReport)
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Why `v` is not an acceptable prepared workspace, or undefined when it is. */
|
|
281
|
+
export function preparedWorkspaceProblem(v: unknown): string | undefined {
|
|
282
|
+
if (!isObj(v)) return "workspace must be an object"
|
|
283
|
+
if (!isAbsolutePath(v.repo)) return "workspace.repo must be an absolute path"
|
|
284
|
+
if (!isAbsolutePath(v.directory)) return "workspace.directory must be an absolute path"
|
|
285
|
+
if (!(WORKSPACE_MODES as readonly unknown[]).includes(v.mode)) return `workspace.mode must be one of ${WORKSPACE_MODES.join(", ")}`
|
|
286
|
+
if (v.branch !== undefined && !isRefName(v.branch)) return "workspace.branch must be a valid branch name"
|
|
287
|
+
if (v.base !== undefined && !isRefName(v.base)) return "workspace.base must be a valid branch name"
|
|
288
|
+
if (v.head !== undefined && !(str(v.head) && COMMIT.test(v.head))) return "workspace.head must be a commit id (7–64 hex digits)"
|
|
289
|
+
if (typeof v.reused !== "boolean") return "workspace.reused must be true or false"
|
|
290
|
+
if (v.runtime !== undefined) return preparedRuntimeProblem(v.runtime)
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function preparedRuntimeProblem(v: unknown): string | undefined {
|
|
295
|
+
if (!isObj(v)) return "workspace.runtime must be an object"
|
|
296
|
+
if (!(RUNTIME_KINDS as readonly unknown[]).includes(v.kind)) return `workspace.runtime.kind must be one of ${RUNTIME_KINDS.join(", ")}`
|
|
297
|
+
if (v.profile !== undefined && !token(v.profile, 64)) return "workspace.runtime.profile must be a short lowercase name"
|
|
298
|
+
if (v.sandbox === undefined) return undefined
|
|
299
|
+
if (v.kind !== "openshell") return "workspace.runtime.sandbox is only for an openshell workspace"
|
|
300
|
+
return isSandboxName(v.sandbox) ? undefined : "workspace.runtime.sandbox must be a lowercase DNS label (1–63 letters, digits and -)"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function artifactsProblem(v: unknown): string | undefined {
|
|
304
|
+
if (!isObj(v)) return "artifacts must be an object"
|
|
305
|
+
if (v.branch !== undefined && !isRefName(v.branch)) return "artifacts.branch must be a valid branch name"
|
|
306
|
+
if (v.base !== undefined && !isRefName(v.base)) return "artifacts.base must be a valid branch name"
|
|
307
|
+
if (v.head !== undefined && !(str(v.head) && COMMIT.test(v.head))) return "artifacts.head must be a commit id (7–64 hex digits)"
|
|
308
|
+
if (v.commits !== undefined && !whole(v.commits, Number.MAX_SAFE_INTEGER)) return "artifacts.commits must be a whole number"
|
|
309
|
+
if (v.dirty !== undefined && typeof v.dirty !== "boolean") return "artifacts.dirty must be true or false"
|
|
310
|
+
return undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isNodeRequest(v: unknown): v is NodeRequest {
|
|
314
|
+
if (!isObj(v) || !launchID(v.launchID)) return false
|
|
315
|
+
switch (v.op) {
|
|
316
|
+
case "prepare":
|
|
317
|
+
return (
|
|
318
|
+
isObj(v.runtime) &&
|
|
319
|
+
(RUNTIME_KINDS as readonly unknown[]).includes(v.runtime.kind) &&
|
|
320
|
+
(v.runtime.profile === undefined || token(v.runtime.profile, 64)) &&
|
|
321
|
+
token(v.harness, 64) &&
|
|
322
|
+
launchWorkspaceProblem(v.workspace) === undefined
|
|
323
|
+
)
|
|
324
|
+
case "collect":
|
|
325
|
+
return preparedWorkspaceProblem(v.workspace) === undefined
|
|
326
|
+
case "cleanup":
|
|
327
|
+
return preparedWorkspaceProblem(v.workspace) === undefined && (CLEANUP_POLICIES as readonly unknown[]).includes(v.policy)
|
|
328
|
+
case "cancel":
|
|
329
|
+
return true
|
|
330
|
+
case "start":
|
|
331
|
+
return preparedWorkspaceProblem(v.workspace) === undefined && token(v.harness, 64) && isHubConnection(v.hub) && (v.env === undefined || isStartEnv(v.env))
|
|
332
|
+
case "stop":
|
|
333
|
+
return preparedWorkspaceProblem(v.workspace) === undefined && (v.mode === undefined || (STOP_MODES as readonly unknown[]).includes(v.mode))
|
|
334
|
+
default:
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Why `v` is not an acceptable result of node op `op`, or undefined when it is (the hub checks every result). */
|
|
340
|
+
export function nodeResultProblem(op: NodeOp, v: unknown): string | undefined {
|
|
341
|
+
if (!isObj(v)) return "result must be an object"
|
|
342
|
+
switch (op) {
|
|
343
|
+
case "prepare":
|
|
344
|
+
return preparedWorkspaceProblem(v.workspace)
|
|
345
|
+
case "collect":
|
|
346
|
+
return artifactsProblem(v.artifacts)
|
|
347
|
+
case "cleanup":
|
|
348
|
+
return flagProblem(v, "removed")
|
|
349
|
+
case "cancel":
|
|
350
|
+
return typeof v.cancelled === "boolean" ? undefined : "cancelled must be true or false"
|
|
351
|
+
case "start":
|
|
352
|
+
return flagProblem(v, "started")
|
|
353
|
+
case "stop":
|
|
354
|
+
return flagProblem(v, "stopped")
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A `{flag: boolean, reason?: message}` result's problem. */
|
|
359
|
+
function flagProblem(v: Record<string, unknown>, flag: string): string | undefined {
|
|
360
|
+
if (typeof v[flag] !== "boolean") return `${flag} must be true or false`
|
|
361
|
+
return v.reason === undefined || message(v.reason) ? undefined : `reason must be 1–${NODE_MESSAGE_MAX} printable characters`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The role of a frame's hello ("node" or "spoke"), or undefined when the frame is no hello. Does not validate the hello. */
|
|
365
|
+
export function helloRole(raw: string | Buffer): HelloRole | undefined {
|
|
366
|
+
const m = parseFrame(raw)
|
|
367
|
+
if (!m || m.t !== "hello") return undefined
|
|
368
|
+
return m.role === NODE_ROLE ? NODE_ROLE : "spoke"
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Parses a frame the hub received from a node. Returns undefined for anything invalid. */
|
|
372
|
+
export function parseNodeMessage(raw: string | Buffer): NodeMessage | undefined {
|
|
373
|
+
const m = parseFrame(raw)
|
|
374
|
+
if (!m) return undefined
|
|
375
|
+
switch (m.t) {
|
|
376
|
+
case "hello":
|
|
377
|
+
return isNodeHello(m) ? (m as unknown as NodeHello) : undefined
|
|
378
|
+
case "capacity":
|
|
379
|
+
return isCapacity(m.capacity) && isCapacity(m.used) && typeof m.draining === "boolean" && (m.labels === undefined || labelsProblem(m.labels) === undefined)
|
|
380
|
+
? (m as unknown as NodeCapacityFrame)
|
|
381
|
+
: undefined
|
|
382
|
+
case "progress":
|
|
383
|
+
return launchID(m.launchID) && phase(m.phase) && (m.message === undefined || message(m.message)) ? (m as unknown as NodeProgress) : undefined
|
|
384
|
+
case "res":
|
|
385
|
+
return isResFrame(m) ? (m as unknown as Res) : undefined
|
|
386
|
+
case "bye":
|
|
387
|
+
return isByeFrame(m) ? (m as unknown as Bye) : undefined
|
|
388
|
+
default:
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Parses a frame a node received from the hub. Returns undefined for anything invalid. */
|
|
394
|
+
export function parseNodeServerMessage(raw: string | Buffer): NodeServerMessage | undefined {
|
|
395
|
+
const m = parseFrame(raw)
|
|
396
|
+
if (!m) return undefined
|
|
397
|
+
switch (m.t) {
|
|
398
|
+
case "welcome":
|
|
399
|
+
return isWelcomeFrame(m) ? (m as unknown as Welcome) : undefined
|
|
400
|
+
case "req":
|
|
401
|
+
return nonEmpty(m.id) && isNodeRequest(m.req) ? (m as unknown as Req<NodeRequest>) : undefined
|
|
402
|
+
case "res":
|
|
403
|
+
return isResFrame(m) ? (m as unknown as Res) : undefined
|
|
404
|
+
case "bye":
|
|
405
|
+
return isByeFrame(m) ? (m as unknown as Bye) : undefined
|
|
406
|
+
default:
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
409
|
+
}
|