@huynle/hub 0.6.1 → 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,419 @@
|
|
|
1
|
+
// Placement (design §6.2, §9; decision znbg8xa5): which of the user's nodes takes a launch.
|
|
2
|
+
// - matchNode is pure: over a snapshot of candidates it applies the hard constraints (the user's own machines only,
|
|
3
|
+
// not draining, the runtime kind, the harness listed for that runtime, the runtime profile when one is named,
|
|
4
|
+
// the workspace under one of the node's roots, for host launches a connected service spoke of the launch's harness
|
|
5
|
+
// announcing "launch" (each harness has its own service spoke: a Claude Code one never serves an OpenCode launch),
|
|
6
|
+
// and for openshell launches the node feature "sandbox" (it starts and stops the harness itself; the sandbox's
|
|
7
|
+
// launch-bound spoke creates the session, so no service spoke is needed)), then the placement's machine and labels (mode require: hard; prefer: ranking only), then
|
|
8
|
+
// free slots (capacity minus used minus reservations). The best node has the preference, then most free slots,
|
|
9
|
+
// then the lowest machine ID. A launch that cannot be placed waits for capacity (a fitting node is full) or for a
|
|
10
|
+
// match (no node fits; the reason names each node's first problem).
|
|
11
|
+
// - Admission (per user, hub.json agents.launches): admit() refuses a new launch with rate_limited while maxQueued
|
|
12
|
+
// launches are queued or perHour were recorded this clock hour (Counters, one key per hour); it consumes
|
|
13
|
+
// nothing, so the engine calls recordLaunch() only when the store really created a launch (not for an
|
|
14
|
+
// idempotent retry). Two racing creates can both pass admit: perHour is a soft cap by that margin.
|
|
15
|
+
// - place(user) is one pass under the user's fenced lease (Locks `placement:<user>`), so two processes never place
|
|
16
|
+
// the same user's launches; the lease is renewed (and its fence checked) before every write and released at
|
|
17
|
+
// the end. A pass first returns launches left placing by a dead holder to the queue, then walks queued launches
|
|
18
|
+
// oldest first: while fewer than maxActive are placing/provisioning/running, each is matched against the
|
|
19
|
+
// user's nodes and moved queued → placing → provisioning on the machine. A launch that cannot be placed now is
|
|
20
|
+
// reported as waiting and does not hold back later ones; earlier launches always pick first. Reservations are
|
|
21
|
+
// launches provisioning or running on a machine that its node does not report (yet); slots taken in the pass
|
|
22
|
+
// count at once. Reasons go on the launch only when it moves; waiting reasons are returned for the engine.
|
|
23
|
+
// - PlacementLoop runs passes: kick(user) when something changed (a launch queued or ended, a node connected or
|
|
24
|
+
// reported capacity, a service spoke came), coalescing kicks during a pass into one more, plus a sweep over
|
|
25
|
+
// users() every intervalMs for whatever a kick missed (e.g. another process held the lease). Each placed launch
|
|
26
|
+
// goes to onPlaced (the launch engine starts provisioning); failures go to onError and never stop the loop.
|
|
27
|
+
// - Node and spoke info come through PlacementNodes / PlacementSpokes, which the launch engine adapts from the
|
|
28
|
+
// NodeRegistry and the Registry; memory is not matched (a spec has no memory need yet).
|
|
29
|
+
import type { NodeCapacity, NodeRuntimeInfo } from "../../protocol"
|
|
30
|
+
import type { Clock, Counters, Locks, Timers } from "../backend/types"
|
|
31
|
+
import type { HubLaunches } from "../config"
|
|
32
|
+
import { AgentError } from "./errors"
|
|
33
|
+
import type { LaunchSpec } from "./launch-spec"
|
|
34
|
+
import { type Launch, type LaunchState, type LaunchStore, LaunchTransitionError } from "./launch-store"
|
|
35
|
+
|
|
36
|
+
/** A connected node as placement sees it (the launch engine adapts the NodeRegistry to this). */
|
|
37
|
+
export interface PlacementNode {
|
|
38
|
+
readonly machineID: string
|
|
39
|
+
/** The machine's name (placement.machine matches it or the ID). */
|
|
40
|
+
readonly name?: string
|
|
41
|
+
/** The user owning the machine. */
|
|
42
|
+
readonly owner: string
|
|
43
|
+
readonly runtimes: readonly NodeRuntimeInfo[]
|
|
44
|
+
readonly labels: Readonly<Record<string, string>>
|
|
45
|
+
readonly capacity: NodeCapacity
|
|
46
|
+
readonly used: NodeCapacity
|
|
47
|
+
readonly workspaceRoots: readonly string[]
|
|
48
|
+
readonly draining: boolean
|
|
49
|
+
/** The launches the node reports carrying (they count in `used`). */
|
|
50
|
+
readonly launchIDs: readonly string[]
|
|
51
|
+
/** Optional node features from its hello (e.g. "sandbox"); missing: none. */
|
|
52
|
+
readonly features?: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A node with what the matcher needs besides its own report. */
|
|
56
|
+
export interface Candidate extends PlacementNode {
|
|
57
|
+
/** The harnesses whose connected service spoke on the machine announces "launch" (host launches need theirs). */
|
|
58
|
+
readonly launchHarnesses: readonly string[]
|
|
59
|
+
/** Slots taken by launches placed on it that the node does not report yet. */
|
|
60
|
+
readonly reserved: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Match =
|
|
64
|
+
| { readonly ok: true; readonly node: PlacementNode; readonly preferred: boolean }
|
|
65
|
+
| { readonly ok: false; readonly wait: "capacity" | "no_match"; readonly reason: string }
|
|
66
|
+
|
|
67
|
+
const label = (node: PlacementNode) => node.name ?? node.machineID
|
|
68
|
+
|
|
69
|
+
const underRoot = (repo: string, root: string): boolean => {
|
|
70
|
+
const base = root.replace(/\/+$/, "")
|
|
71
|
+
return repo === base || repo.startsWith(`${base}/`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Why the node can never take the launch as it is now (undefined: it can, given a free slot). */
|
|
75
|
+
function hardProblem(spec: LaunchSpec, node: Candidate): string | undefined {
|
|
76
|
+
if (node.draining) return "is draining"
|
|
77
|
+
const kind = spec.runtime.kind
|
|
78
|
+
const runtime = node.runtimes.find((r) => r.kind === kind)
|
|
79
|
+
if (!runtime) return `has no ${kind} runtime`
|
|
80
|
+
if (!runtime.harnesses?.includes(spec.harness)) return `has no ${spec.harness} harness`
|
|
81
|
+
if (spec.runtime.profile !== undefined && !runtime.profiles?.includes(spec.runtime.profile)) return `has no ${spec.runtime.profile} profile`
|
|
82
|
+
if (!node.workspaceRoots.some((root) => underRoot(spec.workspace.repo, root))) return `does not serve ${spec.workspace.repo}`
|
|
83
|
+
if (kind === "host" && !node.launchHarnesses.includes(spec.harness)) {
|
|
84
|
+
return node.launchHarnesses.length ? `has no connected ${spec.harness} service spoke with launch` : "has no connected service spoke with launch"
|
|
85
|
+
}
|
|
86
|
+
if (kind === "openshell" && !node.features?.includes("sandbox")) return "does not serve sandboxes"
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Why the node does not match the placement's machine and labels (undefined: it does). */
|
|
91
|
+
function placementProblem(spec: LaunchSpec, node: PlacementNode): string | undefined {
|
|
92
|
+
const { machine, labels } = spec.placement
|
|
93
|
+
if (machine !== undefined && machine !== node.machineID && machine !== node.name) return `is not ${machine}`
|
|
94
|
+
for (const [key, value] of Object.entries(labels)) if (node.labels[key] !== value) return `lacks label ${key}=${value}`
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const freeSlots = (node: Candidate) => node.capacity.slots - node.used.slots - node.reserved
|
|
99
|
+
|
|
100
|
+
/** Picks the user's node for a launch from a snapshot of candidates, or says why it must wait. Pure. */
|
|
101
|
+
export function matchNode(userID: string, spec: LaunchSpec, candidates: readonly Candidate[]): Match {
|
|
102
|
+
const own = candidates.filter((node) => node.owner === userID)
|
|
103
|
+
if (own.length === 0) return { ok: false, wait: "no_match", reason: "No node is connected" }
|
|
104
|
+
const require = spec.placement.mode === "require"
|
|
105
|
+
const problems: string[] = []
|
|
106
|
+
const full: Candidate[] = []
|
|
107
|
+
const free: { node: Candidate; preferred: boolean }[] = []
|
|
108
|
+
for (const node of own) {
|
|
109
|
+
const placement = placementProblem(spec, node)
|
|
110
|
+
const problem = hardProblem(spec, node) ?? (require ? placement : undefined)
|
|
111
|
+
if (problem) problems.push(`${label(node)} ${problem}`)
|
|
112
|
+
else if (freeSlots(node) <= 0) full.push(node)
|
|
113
|
+
else free.push({ node, preferred: placement === undefined })
|
|
114
|
+
}
|
|
115
|
+
if (free.length > 0) {
|
|
116
|
+
free.sort(
|
|
117
|
+
(a, b) =>
|
|
118
|
+
Number(b.preferred) - Number(a.preferred) ||
|
|
119
|
+
freeSlots(b.node) - freeSlots(a.node) ||
|
|
120
|
+
(a.node.machineID < b.node.machineID ? -1 : a.node.machineID > b.node.machineID ? 1 : 0),
|
|
121
|
+
)
|
|
122
|
+
const best = free[0]!
|
|
123
|
+
return { ok: true, node: best.node, preferred: best.preferred }
|
|
124
|
+
}
|
|
125
|
+
if (full.length > 0) return { ok: false, wait: "capacity", reason: `Waiting for a free slot on ${full.map(label).join(", ")}` }
|
|
126
|
+
return { ok: false, wait: "no_match", reason: `No node fits: ${problems.join("; ")}` }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
// ---- admission and the placement pass ---------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** Per-user launch caps (hub.json agents.launches). */
|
|
133
|
+
export interface LaunchCaps {
|
|
134
|
+
/** Launches waiting to be placed. */
|
|
135
|
+
readonly maxQueued: number
|
|
136
|
+
/** Launches placed and not ended (placing, provisioning, running). */
|
|
137
|
+
readonly maxActive: number
|
|
138
|
+
/** New launches per clock hour. */
|
|
139
|
+
readonly perHour: number
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const DEFAULT_LAUNCH_CAPS: LaunchCaps = { maxQueued: 50, maxActive: 4, perHour: 60 }
|
|
143
|
+
export const PLACEMENT_LEASE_MS = 15_000
|
|
144
|
+
const HOUR_MS = 3_600_000
|
|
145
|
+
/** retryAfter (seconds) when the queue is full: placement frees it as nodes do. */
|
|
146
|
+
const QUEUE_RETRY_S = 60
|
|
147
|
+
const ACTIVE: readonly LaunchState[] = ["placing", "provisioning", "running"]
|
|
148
|
+
|
|
149
|
+
/** The user's connected nodes (the launch engine adapts the NodeRegistry). */
|
|
150
|
+
export interface PlacementNodes {
|
|
151
|
+
nodes(userID: string): readonly PlacementNode[] | Promise<readonly PlacementNode[]>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The service spokes (the launch engine adapts the Registry). */
|
|
155
|
+
export interface PlacementSpokes {
|
|
156
|
+
/** The harnesses whose connected service spoke on the machine announces the "launch" feature. */
|
|
157
|
+
launchHarnesses(userID: string, machineID: string): readonly string[] | Promise<readonly string[]>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PlacementDeps {
|
|
161
|
+
readonly store: LaunchStore
|
|
162
|
+
readonly nodes: PlacementNodes
|
|
163
|
+
readonly spokes: PlacementSpokes
|
|
164
|
+
readonly counters: Counters
|
|
165
|
+
readonly locks: Locks
|
|
166
|
+
/** This process (pod) as lease owner. */
|
|
167
|
+
readonly owner: string
|
|
168
|
+
readonly caps?: HubLaunches
|
|
169
|
+
readonly now?: Clock
|
|
170
|
+
readonly leaseMs?: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A launch the pass placed: it is now provisioning on the node's machine. */
|
|
174
|
+
export interface Placed {
|
|
175
|
+
readonly userID: string
|
|
176
|
+
readonly launch: Launch
|
|
177
|
+
readonly node: PlacementNode
|
|
178
|
+
readonly preferred: boolean
|
|
179
|
+
/** The placement lease's fencing token for this pass. */
|
|
180
|
+
readonly fence: number
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface Waiting {
|
|
184
|
+
readonly launchID: string
|
|
185
|
+
/** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
|
|
186
|
+
readonly wait: "capacity" | "no_match" | "active"
|
|
187
|
+
readonly reason: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One pass. leased false: another holder has the user's lease, or it was lost mid-pass (placed: before that). */
|
|
191
|
+
export interface PassResult {
|
|
192
|
+
readonly leased: boolean
|
|
193
|
+
readonly placed: Placed[]
|
|
194
|
+
readonly waiting: Waiting[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const placementLeaseKey = (userID: string) => `placement:${userID}`
|
|
198
|
+
|
|
199
|
+
export class Placement {
|
|
200
|
+
readonly caps: LaunchCaps
|
|
201
|
+
private readonly now: Clock
|
|
202
|
+
private readonly leaseMs: number
|
|
203
|
+
|
|
204
|
+
constructor(private readonly deps: PlacementDeps) {
|
|
205
|
+
this.caps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
|
|
206
|
+
this.now = deps.now ?? Date.now
|
|
207
|
+
this.leaseMs = deps.leaseMs ?? PLACEMENT_LEASE_MS
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Throws rate_limited when the user may not add a launch now (maxQueued, perHour). Consumes nothing. */
|
|
211
|
+
async admit(userID: string): Promise<void> {
|
|
212
|
+
const queued = (await this.deps.store.list(userID, { states: ["queued"] })).length
|
|
213
|
+
if (queued >= this.caps.maxQueued) {
|
|
214
|
+
throw new AgentError("rate_limited", `${queued} launches are queued (the most per user)`, {
|
|
215
|
+
hint: "Wait for queued launches to be placed, or stop some",
|
|
216
|
+
extra: { retryAfter: QUEUE_RETRY_S },
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
const { key, endsAt } = this.hour(userID)
|
|
220
|
+
const count = await this.deps.counters.get(key)
|
|
221
|
+
if (count >= this.caps.perHour) {
|
|
222
|
+
throw new AgentError("rate_limited", `${count} launches this hour (the most per user)`, {
|
|
223
|
+
extra: { retryAfter: Math.ceil((endsAt - this.now()) / 1000) },
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Counts a new launch against perHour (call it when the store created one, not for an idempotent retry). */
|
|
229
|
+
async recordLaunch(userID: string): Promise<void> {
|
|
230
|
+
const { key, endsAt } = this.hour(userID)
|
|
231
|
+
await this.deps.counters.incr(key, endsAt - this.now())
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One placement pass for the user under the user's lease: queued launches in order onto fitting nodes. */
|
|
235
|
+
async place(userID: string): Promise<PassResult> {
|
|
236
|
+
const { locks, owner, store } = this.deps
|
|
237
|
+
const key = placementLeaseKey(userID)
|
|
238
|
+
const lease = await locks.acquire(key, owner, this.leaseMs)
|
|
239
|
+
const placed: Placed[] = []
|
|
240
|
+
const waiting: Waiting[] = []
|
|
241
|
+
if (!lease) return { leased: false, placed, waiting }
|
|
242
|
+
const held = async () => (await locks.renew(key, owner, this.leaseMs))?.fence === lease.fence
|
|
243
|
+
const lost = (): PassResult => ({ leased: false, placed, waiting: [] })
|
|
244
|
+
try {
|
|
245
|
+
let launches = await store.list(userID)
|
|
246
|
+
// Under the lease, a launch still placing was left by a holder that died mid-pass.
|
|
247
|
+
for (const launch of launches.filter((l) => l.state === "placing")) {
|
|
248
|
+
if (!(await held())) return lost()
|
|
249
|
+
await skipGone(store.transition(userID, launch.id, "queued", { reason: "Placement interrupted; queued again", from: ["placing"] }))
|
|
250
|
+
}
|
|
251
|
+
launches = await store.list(userID)
|
|
252
|
+
const queued = launches.filter((l) => l.state === "queued")
|
|
253
|
+
if (queued.length === 0) return { leased: true, placed, waiting }
|
|
254
|
+
let active = launches.filter((l) => ACTIVE.includes(l.state)).length
|
|
255
|
+
const candidates = await this.candidates(userID, launches)
|
|
256
|
+
for (const launch of queued) {
|
|
257
|
+
if (active >= this.caps.maxActive) {
|
|
258
|
+
waiting.push({ launchID: launch.id, wait: "active", reason: `Waiting: ${active} launches are active (the most per user)` })
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
const match = matchNode(userID, launch.spec, candidates)
|
|
262
|
+
if (!match.ok) {
|
|
263
|
+
waiting.push({ launchID: launch.id, wait: match.wait, reason: match.reason })
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const name = match.node.name ?? match.node.machineID
|
|
267
|
+
if (!(await held())) return lost()
|
|
268
|
+
const placing = await skipGone(store.transition(userID, launch.id, "placing", { reason: `Placing on ${name}`, from: ["queued"] }))
|
|
269
|
+
if (!placing) continue
|
|
270
|
+
if (!(await held())) return lost()
|
|
271
|
+
const provisioning = await store.transition(userID, launch.id, "provisioning", {
|
|
272
|
+
reason: `Placed on ${name}${match.preferred ? "" : " (preferred placement not available)"}`,
|
|
273
|
+
machineID: match.node.machineID,
|
|
274
|
+
from: ["placing"],
|
|
275
|
+
})
|
|
276
|
+
const index = candidates.findIndex((c) => c.machineID === match.node.machineID)
|
|
277
|
+
candidates[index] = { ...candidates[index]!, reserved: candidates[index]!.reserved + 1 }
|
|
278
|
+
active++
|
|
279
|
+
placed.push({ userID, launch: provisioning, node: match.node, preferred: match.preferred, fence: lease.fence })
|
|
280
|
+
}
|
|
281
|
+
return { leased: true, placed, waiting }
|
|
282
|
+
} finally {
|
|
283
|
+
await locks.release(key, owner)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The user's nodes with their service spoke and reservations (placed launches the node does not report yet). */
|
|
288
|
+
private async candidates(userID: string, launches: readonly Launch[]): Promise<Candidate[]> {
|
|
289
|
+
const nodes = (await this.deps.nodes.nodes(userID)).filter((node) => node.owner === userID)
|
|
290
|
+
return Promise.all(
|
|
291
|
+
nodes.map(async (node) => ({
|
|
292
|
+
...node,
|
|
293
|
+
launchHarnesses: await this.deps.spokes.launchHarnesses(userID, node.machineID),
|
|
294
|
+
reserved: launches.filter((l) => l.machineID === node.machineID && ACTIVE.includes(l.state) && !node.launchIDs.includes(l.id)).length,
|
|
295
|
+
})),
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private hour(userID: string): { key: string; endsAt: number } {
|
|
300
|
+
const bucket = Math.floor(this.now() / HOUR_MS)
|
|
301
|
+
return { key: `launches:hour:${userID}:${bucket}`, endsAt: (bucket + 1) * HOUR_MS }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A step that lost a race with the launch ending (or moving on): undefined, not an error. */
|
|
306
|
+
async function skipGone<T>(step: Promise<T>): Promise<T | undefined> {
|
|
307
|
+
try {
|
|
308
|
+
return await step
|
|
309
|
+
} catch (cause) {
|
|
310
|
+
if (cause instanceof LaunchTransitionError) return undefined
|
|
311
|
+
throw cause
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
// ---- the loop ---------------------------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export const PLACEMENT_SWEEP_MS = 5_000
|
|
319
|
+
|
|
320
|
+
export interface PlacementLoopDeps {
|
|
321
|
+
readonly placement: Pick<Placement, "place">
|
|
322
|
+
/** The users that may have launches to place (e.g. the store's users). */
|
|
323
|
+
readonly users: () => Promise<readonly string[]>
|
|
324
|
+
/** The engine takes a placed (provisioning) launch from here. */
|
|
325
|
+
readonly onPlaced: (placed: Placed) => void | Promise<void>
|
|
326
|
+
readonly onError?: (cause: unknown, userID?: string) => void
|
|
327
|
+
readonly timers?: Timers
|
|
328
|
+
readonly intervalMs?: number
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const defaultTimers: Timers = { set: (fn, ms) => setTimeout(fn, ms), clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>) }
|
|
332
|
+
|
|
333
|
+
/** Runs placement passes: on kick(user) (a launch queued or ended, a node's capacity changed) and on a sweep. */
|
|
334
|
+
export class PlacementLoop {
|
|
335
|
+
private readonly runs = new Map<string, { again: boolean; done: Promise<void> }>()
|
|
336
|
+
private readonly timers: Timers
|
|
337
|
+
private readonly intervalMs: number
|
|
338
|
+
private readonly onError: (cause: unknown, userID?: string) => void
|
|
339
|
+
private timer: unknown
|
|
340
|
+
private running = false
|
|
341
|
+
|
|
342
|
+
constructor(private readonly deps: PlacementLoopDeps) {
|
|
343
|
+
this.timers = deps.timers ?? defaultTimers
|
|
344
|
+
this.intervalMs = deps.intervalMs ?? PLACEMENT_SWEEP_MS
|
|
345
|
+
this.onError = deps.onError ?? ((cause, userID) => console.error(`[hub] placement failed${userID ? ` for ${userID}` : ""}:`, cause))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A pass for the user; a kick during a pass makes one more pass after it. Resolves when they are done. */
|
|
349
|
+
kick(userID: string): Promise<void> {
|
|
350
|
+
const current = this.runs.get(userID)
|
|
351
|
+
if (current) {
|
|
352
|
+
current.again = true
|
|
353
|
+
return current.done
|
|
354
|
+
}
|
|
355
|
+
const run = { again: false, done: Promise.resolve() }
|
|
356
|
+
this.runs.set(userID, run)
|
|
357
|
+
run.done = (async () => {
|
|
358
|
+
try {
|
|
359
|
+
do {
|
|
360
|
+
run.again = false
|
|
361
|
+
await this.pass(userID)
|
|
362
|
+
} while (run.again)
|
|
363
|
+
} finally {
|
|
364
|
+
this.runs.delete(userID)
|
|
365
|
+
}
|
|
366
|
+
})()
|
|
367
|
+
return run.done
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Sweeps every intervalMs (idempotent). */
|
|
371
|
+
start(): void {
|
|
372
|
+
this.running = true
|
|
373
|
+
if (this.timer === undefined) this.schedule()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
stop(): void {
|
|
377
|
+
this.running = false
|
|
378
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
379
|
+
this.timer = undefined
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A pass for every user, one after another. */
|
|
383
|
+
async sweep(): Promise<void> {
|
|
384
|
+
let users: readonly string[]
|
|
385
|
+
try {
|
|
386
|
+
users = await this.deps.users()
|
|
387
|
+
} catch (cause) {
|
|
388
|
+
this.onError(cause)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
for (const userID of users) await this.kick(userID)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private schedule(): void {
|
|
395
|
+
this.timer = this.timers.set(() => {
|
|
396
|
+
this.timer = undefined
|
|
397
|
+
void this.sweep().finally(() => {
|
|
398
|
+
if (this.running && this.timer === undefined) this.schedule()
|
|
399
|
+
})
|
|
400
|
+
}, this.intervalMs)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async pass(userID: string): Promise<void> {
|
|
404
|
+
let result: PassResult
|
|
405
|
+
try {
|
|
406
|
+
result = await this.deps.placement.place(userID)
|
|
407
|
+
} catch (cause) {
|
|
408
|
+
this.onError(cause, userID)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
for (const placed of result.placed) {
|
|
412
|
+
try {
|
|
413
|
+
await this.deps.onPlaced(placed)
|
|
414
|
+
} catch (cause) {
|
|
415
|
+
this.onError(cause, userID)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|