@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,232 @@
|
|
|
1
|
+
// Leader loops (design §9, decision vkxkqiuf §7): a timed job (decision deadlines, message re-wakes, the webhook
|
|
2
|
+
// sender, the launch supervisor) runs on one pod at a time, the holder of a fenced lease `leader:<name>` in the
|
|
3
|
+
// shared Locks. The single-process hub runs the same code on in-memory locks: it is always the leader.
|
|
4
|
+
// - start(): tries to take the lease at once, then every renewMs: the leader renews it, the others try to take it.
|
|
5
|
+
// Taking it calls onElected (with the lease: its fence grows with every new leader); losing it (taken, expired, or
|
|
6
|
+
// a renewal failing too close to its expiry) calls onDeposed, so a leader stops before its lease can run out.
|
|
7
|
+
// - stop(): steps down, releases the lease and tells the other pods (LEADER topic `.released`), which try at once:
|
|
8
|
+
// a graceful failover takes one bus hop; a dead leader is replaced within ttlMs (+ one renewMs tick).
|
|
9
|
+
// - kick(message): any pod nudges the leader (onKick runs there only), e.g. "a launch was queued".
|
|
10
|
+
// - request(message): any pod asks the leader (onRequest), wherever it runs (Bus RPC on leader.<name>.<owner>.rpc);
|
|
11
|
+
// NoLeaderError when nobody holds the lease or the holder no longer answers.
|
|
12
|
+
import { realTimers } from "../backend/memory-bus"
|
|
13
|
+
import { BusNoResponderError, type Bus, type Clock, type Data, type Lease, type Locks, type RequestOptions, type Timers, type Unsubscribe } from "../backend/types"
|
|
14
|
+
|
|
15
|
+
/** How long a leader lease lasts without renewal (a dead leader is replaced after this). */
|
|
16
|
+
export const LEADER_TTL_MS = 15_000
|
|
17
|
+
|
|
18
|
+
/** The Locks key of a job's leader lease. */
|
|
19
|
+
export const leaderKey = (name: string) => `leader:${name}`
|
|
20
|
+
const kickTopic = (name: string) => `leader.${name}.kick`
|
|
21
|
+
const releasedTopic = (name: string) => `leader.${name}.released`
|
|
22
|
+
const requestTopic = (name: string, owner: string) => `leader.${name}.${owner}.rpc`
|
|
23
|
+
|
|
24
|
+
/** Nobody leads the job (or its leader went away): the request cannot be answered now. */
|
|
25
|
+
export class NoLeaderError extends Error {
|
|
26
|
+
constructor(name: string) {
|
|
27
|
+
super(`No leader runs ${name} right now`)
|
|
28
|
+
this.name = "NoLeaderError"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LeaderLoopOptions {
|
|
33
|
+
readonly locks: Locks
|
|
34
|
+
readonly bus: Bus
|
|
35
|
+
/** The job (lease key leader:<name>, topics leader.<name>.*). */
|
|
36
|
+
readonly name: string
|
|
37
|
+
/** This pod's process (unique per process: a restarted pod is a new owner). */
|
|
38
|
+
readonly owner: string
|
|
39
|
+
/** Lease time (default LEADER_TTL_MS). */
|
|
40
|
+
readonly ttlMs?: number
|
|
41
|
+
/** How often the leader renews and the others try (default a third of ttlMs). */
|
|
42
|
+
readonly renewMs?: number
|
|
43
|
+
readonly timers?: Timers
|
|
44
|
+
readonly now?: Clock
|
|
45
|
+
/** This pod leads now: start the job. */
|
|
46
|
+
readonly onElected?: (lease: Lease) => void | Promise<void>
|
|
47
|
+
/** This pod no longer leads: stop the job. */
|
|
48
|
+
readonly onDeposed?: () => void | Promise<void>
|
|
49
|
+
readonly onKick?: (message: Data) => void
|
|
50
|
+
readonly onRequest?: (message: Data) => Data | Promise<Data>
|
|
51
|
+
readonly onError?: (error: unknown) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class LeaderLoop {
|
|
55
|
+
readonly name: string
|
|
56
|
+
private readonly options: LeaderLoopOptions
|
|
57
|
+
private readonly ttlMs: number
|
|
58
|
+
private readonly renewMs: number
|
|
59
|
+
private readonly timers: Timers
|
|
60
|
+
private readonly now: Clock
|
|
61
|
+
private readonly onError: (error: unknown) => void
|
|
62
|
+
private lease: Lease | undefined
|
|
63
|
+
/** Until when (this pod's clock) the lease surely holds: the time the last successful take or renewal started + ttl. */
|
|
64
|
+
private validUntil = 0
|
|
65
|
+
private running = false
|
|
66
|
+
private timer: unknown
|
|
67
|
+
private queue: Promise<void> = Promise.resolve()
|
|
68
|
+
private readonly subscriptions: Unsubscribe[] = []
|
|
69
|
+
private serving: Unsubscribe | undefined
|
|
70
|
+
|
|
71
|
+
constructor(options: LeaderLoopOptions) {
|
|
72
|
+
this.options = options
|
|
73
|
+
this.name = options.name
|
|
74
|
+
this.ttlMs = options.ttlMs ?? LEADER_TTL_MS
|
|
75
|
+
this.renewMs = options.renewMs ?? Math.max(1, Math.floor(this.ttlMs / 3))
|
|
76
|
+
this.timers = options.timers ?? realTimers
|
|
77
|
+
this.now = options.now ?? Date.now
|
|
78
|
+
this.onError = options.onError ?? ((error) => console.error(`[hub] leader ${options.name}: ${error instanceof Error ? error.message : String(error)}`))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether this pod leads the job now. */
|
|
82
|
+
isLeader(): boolean {
|
|
83
|
+
return this.lease !== undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The current lease's fence (undefined when not leading). */
|
|
87
|
+
fence(): number | undefined {
|
|
88
|
+
return this.lease?.fence
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Listens for kicks and releases, tries to take the lease and keeps trying (or renewing) every renewMs. */
|
|
92
|
+
async start(): Promise<void> {
|
|
93
|
+
if (this.running) return
|
|
94
|
+
this.running = true
|
|
95
|
+
const { bus, name } = this.options
|
|
96
|
+
try {
|
|
97
|
+
this.subscriptions.push(
|
|
98
|
+
await bus.subscribe(kickTopic(name), (message) => {
|
|
99
|
+
if (this.lease) this.options.onKick?.(message)
|
|
100
|
+
}),
|
|
101
|
+
await bus.subscribe(releasedTopic(name), () => {
|
|
102
|
+
if (this.running && !this.lease) void this.enqueue(() => this.attempt())
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
this.onError(error)
|
|
107
|
+
}
|
|
108
|
+
await this.enqueue(() => this.attempt())
|
|
109
|
+
this.schedule()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Steps down (releasing the lease and telling the other pods) and stops trying. */
|
|
113
|
+
async stop(): Promise<void> {
|
|
114
|
+
if (!this.running) return
|
|
115
|
+
this.running = false
|
|
116
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
117
|
+
this.timer = undefined
|
|
118
|
+
const subscriptions = this.subscriptions.splice(0)
|
|
119
|
+
await this.enqueue(async () => {
|
|
120
|
+
if (this.lease) {
|
|
121
|
+
await this.depose()
|
|
122
|
+
await this.options.locks.release(leaderKey(this.name), this.options.owner).catch((error) => this.onError(error))
|
|
123
|
+
await this.options.bus.publish(releasedTopic(this.name), { owner: this.options.owner }).catch((error) => this.onError(error))
|
|
124
|
+
}
|
|
125
|
+
await Promise.allSettled(subscriptions.map((unsubscribe) => unsubscribe()))
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Nudges the leader (wherever it runs). */
|
|
130
|
+
async kick(message: Data = {}): Promise<void> {
|
|
131
|
+
await this.options.bus.publish(kickTopic(this.name), message)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Asks the leader (here or on another pod); NoLeaderError when there is none. */
|
|
135
|
+
async request<T = Data>(message: Data, options: RequestOptions): Promise<T> {
|
|
136
|
+
const holder = await this.options.locks.holder(leaderKey(this.name))
|
|
137
|
+
if (!holder) throw new NoLeaderError(this.name)
|
|
138
|
+
if (holder.owner === this.options.owner && this.lease) {
|
|
139
|
+
if (!this.options.onRequest) throw new NoLeaderError(this.name)
|
|
140
|
+
return (await this.options.onRequest(structuredClone(message))) as T
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return await this.options.bus.request<T>(requestTopic(this.name, holder.owner), message, options)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof BusNoResponderError) throw new NoLeaderError(this.name)
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
private schedule(): void {
|
|
153
|
+
if (!this.running) return
|
|
154
|
+
this.timer = this.timers.set(() => {
|
|
155
|
+
this.timer = undefined
|
|
156
|
+
void this.enqueue(() => this.attempt()).finally(() => this.schedule())
|
|
157
|
+
}, this.renewMs)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Renews the lease (leader) or tries to take it (the others). */
|
|
161
|
+
private async attempt(): Promise<void> {
|
|
162
|
+
if (!this.running) return
|
|
163
|
+
const { locks, owner } = this.options
|
|
164
|
+
const key = leaderKey(this.name)
|
|
165
|
+
const started = this.now()
|
|
166
|
+
if (this.lease) {
|
|
167
|
+
let renewed: Lease | undefined
|
|
168
|
+
try {
|
|
169
|
+
renewed = await locks.renew(key, owner, this.ttlMs)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
this.onError(error)
|
|
172
|
+
// Not sure whether it still holds: step down while the lease surely lasts one more tick.
|
|
173
|
+
if (this.now() + this.renewMs >= this.validUntil) await this.depose()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (!renewed) {
|
|
177
|
+
await this.depose()
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
this.lease = renewed
|
|
181
|
+
this.validUntil = started + this.ttlMs
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
let taken: Lease | undefined
|
|
185
|
+
try {
|
|
186
|
+
taken = await locks.acquire(key, owner, this.ttlMs)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.onError(error)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (!taken) return
|
|
192
|
+
this.lease = taken
|
|
193
|
+
this.validUntil = started + this.ttlMs
|
|
194
|
+
await this.elect(taken)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async elect(lease: Lease): Promise<void> {
|
|
198
|
+
const { bus, name, owner } = this.options
|
|
199
|
+
try {
|
|
200
|
+
this.serving = await bus.serve(requestTopic(name, owner), async (message) => {
|
|
201
|
+
if (!this.lease || !this.options.onRequest) throw new NoLeaderError(name)
|
|
202
|
+
return this.options.onRequest(message)
|
|
203
|
+
})
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.onError(error)
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
await this.options.onElected?.(lease)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
this.onError(error)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async depose(): Promise<void> {
|
|
215
|
+
if (!this.lease) return
|
|
216
|
+
this.lease = undefined
|
|
217
|
+
const serving = this.serving
|
|
218
|
+
this.serving = undefined
|
|
219
|
+
await serving?.().catch((error) => this.onError(error))
|
|
220
|
+
try {
|
|
221
|
+
await this.options.onDeposed?.()
|
|
222
|
+
} catch (error) {
|
|
223
|
+
this.onError(error)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private enqueue(task: () => Promise<void>): Promise<void> {
|
|
228
|
+
const run = this.queue.then(task, task)
|
|
229
|
+
this.queue = run.catch((error) => this.onError(error))
|
|
230
|
+
return this.queue
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The console live stream across pods (design §9, decision vkxkqiuf §6). A user's events go to every pod over the
|
|
2
|
+
// Bus topic user.<id>.live, so a console connected to any pod sees what happens on the pod holding the spoke (status,
|
|
3
|
+
// activity, streamed text, messages, decisions, launches, agent audit). Every event except streamed text (delta,
|
|
4
|
+
// reasoning) is appended to the shared EventLog first and carries its id, so a console that reconnects to another
|
|
5
|
+
// pod resumes by Last-Event-ID; streamed text is broadcast only. Events of one user are sent in order from a pod.
|
|
6
|
+
// Catalog changes are not user events here: each pod rebuilds its consoles' catalog from its own (cluster-wide)
|
|
7
|
+
// registry; publishCatalog() only tells every pod that the stored machines changed.
|
|
8
|
+
import type { Bus, EventLog, EventMessage, Unsubscribe } from "../backend/types"
|
|
9
|
+
|
|
10
|
+
export const liveTopic = (userID: string): string => `user.${userID}.live`
|
|
11
|
+
/** Stored machines changed (approved, revoked, moved): every pod sends its consoles a fresh catalog. */
|
|
12
|
+
export const CATALOG_TOPIC = "live.catalog"
|
|
13
|
+
/** Broadcast only, never logged (high volume; a resumed console refetches the transcript). */
|
|
14
|
+
export const BROADCAST_ONLY: ReadonlySet<string> = new Set(["delta", "reasoning"])
|
|
15
|
+
|
|
16
|
+
/** One user event as it travels the Bus. */
|
|
17
|
+
export interface LiveEvent {
|
|
18
|
+
/** The pod that sent it. */
|
|
19
|
+
readonly pod: string
|
|
20
|
+
readonly message: EventMessage
|
|
21
|
+
/** Its EventLog id (absent for streamed text). */
|
|
22
|
+
readonly id?: string
|
|
23
|
+
/** Not for this device (the one that made the change). */
|
|
24
|
+
readonly exceptDevice?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LiveSubscription {
|
|
28
|
+
/** Resolves once the pod listens on the user's topic. */
|
|
29
|
+
readonly ready: Promise<void>
|
|
30
|
+
close(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LiveFanoutOptions {
|
|
34
|
+
readonly bus: Bus
|
|
35
|
+
readonly events: EventLog
|
|
36
|
+
/** This pod (HUB_POD_ID). */
|
|
37
|
+
readonly podID: string
|
|
38
|
+
readonly onError?: (error: unknown) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface UserTopic {
|
|
42
|
+
readonly handlers: Set<(event: LiveEvent) => void>
|
|
43
|
+
readonly subscribed: Promise<Unsubscribe | undefined>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isLiveEvent = (value: unknown): value is LiveEvent => {
|
|
47
|
+
const event = value as LiveEvent | null
|
|
48
|
+
return typeof event === "object" && event !== null && typeof event.message === "object" && event.message !== null && typeof event.message.type === "string"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class LiveFanout {
|
|
52
|
+
readonly podID: string
|
|
53
|
+
private readonly options: LiveFanoutOptions
|
|
54
|
+
private readonly onError: (error: unknown) => void
|
|
55
|
+
/** Per user: the last send in flight (sends of one user go out in order). */
|
|
56
|
+
private readonly sending = new Map<string, Promise<void>>()
|
|
57
|
+
/** Users this pod listens to, with their local handlers. */
|
|
58
|
+
private readonly topics = new Map<string, UserTopic>()
|
|
59
|
+
private readonly catalogHandlers = new Set<() => void>()
|
|
60
|
+
private catalogSubscription?: Promise<Unsubscribe>
|
|
61
|
+
|
|
62
|
+
constructor(options: LiveFanoutOptions) {
|
|
63
|
+
this.options = options
|
|
64
|
+
this.podID = options.podID
|
|
65
|
+
this.onError = options.onError ?? ((error) => console.error(`[hub] live fan-out failed: ${error instanceof Error ? error.message : String(error)}`))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Listens for catalog announcements. */
|
|
69
|
+
async start(): Promise<void> {
|
|
70
|
+
this.catalogSubscription ??= this.options.bus.subscribe(CATALOG_TOPIC, () => {
|
|
71
|
+
for (const handler of [...this.catalogHandlers]) this.guard(handler)
|
|
72
|
+
})
|
|
73
|
+
await this.catalogSubscription
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Drops this pod's subscriptions (once they are made: a Bus that never connected must not hold up a stop). */
|
|
77
|
+
async stop(): Promise<void> {
|
|
78
|
+
const catalog = this.catalogSubscription
|
|
79
|
+
this.catalogSubscription = undefined
|
|
80
|
+
const topics = [...this.topics.values()]
|
|
81
|
+
this.topics.clear()
|
|
82
|
+
for (const pending of [catalog, ...topics.map((topic) => topic.subscribed)]) void pending?.then((off) => off?.()).catch(this.onError)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Logs (unless streamed text) and publishes a user's event; resolves once it is on the Bus. */
|
|
86
|
+
send(userID: string, message: EventMessage, exceptDevice?: string): Promise<void> {
|
|
87
|
+
const previous = this.sending.get(userID) ?? Promise.resolve()
|
|
88
|
+
const next = previous.then(async () => {
|
|
89
|
+
const id = BROADCAST_ONLY.has(message.type) ? undefined : await this.options.events.append(userID, message, exceptDevice ? { exceptDevice } : {})
|
|
90
|
+
const event: LiveEvent = { pod: this.podID, message, ...(id ? { id } : {}), ...(exceptDevice ? { exceptDevice } : {}) }
|
|
91
|
+
await this.options.bus.publish(liveTopic(userID), event)
|
|
92
|
+
})
|
|
93
|
+
const tail = next.catch(this.onError)
|
|
94
|
+
this.sending.set(userID, tail)
|
|
95
|
+
void tail.then(() => {
|
|
96
|
+
if (this.sending.get(userID) === tail) this.sending.delete(userID)
|
|
97
|
+
})
|
|
98
|
+
return next
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The user's events from every pod (this one included). */
|
|
102
|
+
subscribe(userID: string, handler: (event: LiveEvent) => void): LiveSubscription {
|
|
103
|
+
let topic = this.topics.get(userID)
|
|
104
|
+
if (!topic) {
|
|
105
|
+
const handlers = new Set<(event: LiveEvent) => void>()
|
|
106
|
+
const subscribed = this.options.bus
|
|
107
|
+
.subscribe(liveTopic(userID), (event) => {
|
|
108
|
+
if (!isLiveEvent(event)) return
|
|
109
|
+
for (const h of [...handlers]) this.guard(() => h(event))
|
|
110
|
+
})
|
|
111
|
+
.catch((error: unknown) => {
|
|
112
|
+
this.onError(error)
|
|
113
|
+
return undefined
|
|
114
|
+
})
|
|
115
|
+
topic = { handlers, subscribed }
|
|
116
|
+
this.topics.set(userID, topic)
|
|
117
|
+
}
|
|
118
|
+
const current = topic
|
|
119
|
+
current.handlers.add(handler)
|
|
120
|
+
let closed = false
|
|
121
|
+
return {
|
|
122
|
+
ready: current.subscribed.then(() => {}),
|
|
123
|
+
close: () => {
|
|
124
|
+
if (closed) return
|
|
125
|
+
closed = true
|
|
126
|
+
current.handlers.delete(handler)
|
|
127
|
+
if (current.handlers.size || this.topics.get(userID) !== current) return
|
|
128
|
+
this.topics.delete(userID)
|
|
129
|
+
void current.subscribed.then((off) => off?.()).catch(this.onError)
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Tells every pod (this one included) that the stored machines changed. */
|
|
135
|
+
async publishCatalog(): Promise<void> {
|
|
136
|
+
await this.options.bus.publish(CATALOG_TOPIC, { pod: this.podID })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
onCatalog(handler: () => void): () => void {
|
|
140
|
+
this.catalogHandlers.add(handler)
|
|
141
|
+
return () => void this.catalogHandlers.delete(handler)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private guard(fn: () => void): void {
|
|
145
|
+
try {
|
|
146
|
+
fn()
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this.onError(error)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Recommendations on permissions held by another pod (design §5, §9; decision vkxkqiuf §5). An escalation shows the
|
|
2
|
+
// agent's recommendation on the request's blocker (Registry.recommend), and only the pod holding the request (its
|
|
3
|
+
// spoke) can change that blocker: the other pods see it through the cluster view. recommend() applies it here when
|
|
4
|
+
// this pod holds the request, else publishes it on RECOMMEND_TOPIC, where every pod tries it on its own registry (only
|
|
5
|
+
// the holder has it). Closing escalations needs no routing: the holder's permission-closed listener closes their
|
|
6
|
+
// Decisions in the shared state.
|
|
7
|
+
import type { Bus, Unsubscribe } from "../backend/types"
|
|
8
|
+
import type { BlockerRecommendation } from "../../status"
|
|
9
|
+
|
|
10
|
+
export const RECOMMEND_TOPIC = "cluster.recommend"
|
|
11
|
+
|
|
12
|
+
interface RecommendMessage {
|
|
13
|
+
readonly from: string
|
|
14
|
+
readonly requestID: string
|
|
15
|
+
readonly recommendation: BlockerRecommendation
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ClusterRecommendationsOptions {
|
|
19
|
+
readonly bus: Bus
|
|
20
|
+
/** This pod (HUB_POD_ID). */
|
|
21
|
+
readonly podID: string
|
|
22
|
+
readonly registry: { recommend(requestID: string, recommendation: BlockerRecommendation): boolean }
|
|
23
|
+
readonly onError?: (error: unknown) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ClusterRecommendations {
|
|
27
|
+
private readonly options: ClusterRecommendationsOptions
|
|
28
|
+
private readonly onError: (error: unknown) => void
|
|
29
|
+
private unsubscribe: Unsubscribe | undefined
|
|
30
|
+
|
|
31
|
+
constructor(options: ClusterRecommendationsOptions) {
|
|
32
|
+
this.options = options
|
|
33
|
+
this.onError = options.onError ?? ((error) => console.error(`[hub] cluster recommendation: ${error instanceof Error ? error.message : String(error)}`))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Applies other pods' recommendations for requests this pod holds. */
|
|
37
|
+
async start(): Promise<void> {
|
|
38
|
+
if (this.unsubscribe) return
|
|
39
|
+
this.unsubscribe = await this.options.bus.subscribe(RECOMMEND_TOPIC, (message) => {
|
|
40
|
+
const m = message as Partial<RecommendMessage> | undefined
|
|
41
|
+
if (m?.from === this.options.podID || typeof m?.requestID !== "string" || typeof m.recommendation !== "object" || m.recommendation === null) return
|
|
42
|
+
this.options.registry.recommend(m.requestID, m.recommendation)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async stop(): Promise<void> {
|
|
47
|
+
const unsubscribe = this.unsubscribe
|
|
48
|
+
this.unsubscribe = undefined
|
|
49
|
+
await unsubscribe?.()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Shows the recommendation on the request's blocker: here, or (true: sent) on the pod holding it. */
|
|
53
|
+
recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
|
|
54
|
+
if (this.options.registry.recommend(requestID, recommendation)) return true
|
|
55
|
+
const message: RecommendMessage = { from: this.options.podID, requestID, recommendation }
|
|
56
|
+
void this.options.bus.publish(RECOMMEND_TOPIC, message).catch((error) => this.onError(error))
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Pod RPC (design §9, decision vkxkqiuf §5): ops for a spoke or node connection held by another pod travel as a
|
|
2
|
+
// Bus request on pod.<podID>.rpc. The serving pod runs the op on its own socket (with the same per-op timeout as a
|
|
3
|
+
// local request and a tenant re-check, see SpokeHub) and replies {ok, result} or {ok:false, error:{name, message}};
|
|
4
|
+
// the calling pod turns that back into SpokeError / NodeError / RequestTimeoutError / RequestTooLargeError, so
|
|
5
|
+
// callers cannot tell a remote connection from a local one. The Bus waits RPC_SLACK_MS longer than the op so the
|
|
6
|
+
// serving pod's own timeout answers first; a pod with no responder is gone (the connection is offline).
|
|
7
|
+
// Session-token and act-token pushes (LOCAL_ONLY_OPS) never cross the Bus: refused on both ends.
|
|
8
|
+
// Close notices (machine revoked, launch ended) are published to every pod, the sender included.
|
|
9
|
+
import type { NodeRequest, ServerRequest } from "../../protocol"
|
|
10
|
+
import { BusNoResponderError, BusTimeoutError, type Bus, type Unsubscribe } from "../backend/types"
|
|
11
|
+
import { NodeError, RequestTimeoutError, RequestTooLargeError, SpokeError } from "../spoke-ws"
|
|
12
|
+
|
|
13
|
+
/** How much longer the calling pod waits than the op's own timeout. */
|
|
14
|
+
export const RPC_SLACK_MS = 2_000
|
|
15
|
+
/** Ops that carry credentials for the spoke's shells and agents: sent only by the pod holding the connection. */
|
|
16
|
+
export const LOCAL_ONLY_OPS: ReadonlySet<string> = new Set(["sessionTokens", "actTokens"])
|
|
17
|
+
/** The topic a pod answers ops for its connections on. */
|
|
18
|
+
export const rpcTopic = (podID: string) => `pod.${podID}.rpc`
|
|
19
|
+
/** Close notices for every pod. */
|
|
20
|
+
export const CLOSE_TOPIC = "cluster.close"
|
|
21
|
+
|
|
22
|
+
/** An op for a connection on another pod; `tenant` is the user the calling pod acts for (checked there). */
|
|
23
|
+
export type RpcCall =
|
|
24
|
+
| { readonly kind: "spoke"; readonly connID: string; readonly tenant: string; readonly req: ServerRequest }
|
|
25
|
+
| { readonly kind: "node"; readonly connID: string; readonly tenant: string; readonly req: NodeRequest }
|
|
26
|
+
|
|
27
|
+
/** Disconnect a machine's sockets (revoked) or a launch's (ended) on every pod. */
|
|
28
|
+
export interface CloseNotice {
|
|
29
|
+
readonly kind: "machine" | "launch"
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly reason: string
|
|
32
|
+
/** The pod that published it. */
|
|
33
|
+
readonly from: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface WireError {
|
|
37
|
+
readonly name: string
|
|
38
|
+
readonly message: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RpcReply = { readonly ok: true; readonly result?: unknown } | { readonly ok: false; readonly error: WireError }
|
|
42
|
+
|
|
43
|
+
export interface PodRpcOptions {
|
|
44
|
+
readonly bus: Bus
|
|
45
|
+
/** This pod (HUB_POD_ID). */
|
|
46
|
+
readonly podID: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class PodRpc {
|
|
50
|
+
readonly podID: string
|
|
51
|
+
private readonly bus: Bus
|
|
52
|
+
private readonly unsubscribes: Unsubscribe[] = []
|
|
53
|
+
|
|
54
|
+
constructor(options: PodRpcOptions) {
|
|
55
|
+
this.bus = options.bus
|
|
56
|
+
this.podID = options.podID
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Answers ops for this pod's connections with `handler` (its errors go back to the caller). */
|
|
60
|
+
async serve(handler: (call: RpcCall) => Promise<unknown>): Promise<void> {
|
|
61
|
+
const unsubscribe = await this.bus.serve(rpcTopic(this.podID), async (message): Promise<RpcReply> => {
|
|
62
|
+
const call = message as RpcCall
|
|
63
|
+
try {
|
|
64
|
+
if (!isCall(call)) throw new SpokeError("Invalid pod RPC call")
|
|
65
|
+
if (LOCAL_ONLY_OPS.has(call.req.op)) throw new SpokeError(`The ${call.req.op} op is not sent across pods`)
|
|
66
|
+
return { ok: true, result: await handler(call) }
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
const error = cause instanceof Error ? cause : new Error(String(cause))
|
|
69
|
+
return { ok: false, error: { name: error.name, message: error.message } }
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
this.unsubscribes.push(unsubscribe)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Runs an op on `pod`'s connection and resolves with its result. `timeoutMs` is the op's own timeout; `onGone`
|
|
77
|
+
* is told when the pod has no responder (it is gone: forget its directory entry).
|
|
78
|
+
*/
|
|
79
|
+
async call(pod: string, call: RpcCall, timeoutMs: number, options: { readonly onGone?: () => void } = {}): Promise<unknown> {
|
|
80
|
+
if (LOCAL_ONLY_OPS.has(call.req.op)) throw new SpokeError(`The ${call.req.op} op is not sent across pods`)
|
|
81
|
+
let reply: RpcReply
|
|
82
|
+
try {
|
|
83
|
+
reply = await this.bus.request<RpcReply>(rpcTopic(pod), call, { timeoutMs: timeoutMs + RPC_SLACK_MS })
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
if (cause instanceof BusNoResponderError) {
|
|
86
|
+
options.onGone?.()
|
|
87
|
+
throw offline(call)
|
|
88
|
+
}
|
|
89
|
+
if (cause instanceof BusTimeoutError) throw timeout(call)
|
|
90
|
+
throw failed(call, cause instanceof Error ? cause.message : String(cause))
|
|
91
|
+
}
|
|
92
|
+
if (reply?.ok === true) return reply.result
|
|
93
|
+
if (reply?.ok === false && typeof reply.error?.message === "string") throw revive(call, reply.error)
|
|
94
|
+
throw failed(call, "Invalid pod RPC reply")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tells every pod (this one included) to close a machine's or a launch's sockets. */
|
|
98
|
+
async publishClose(notice: Omit<CloseNotice, "from">): Promise<void> {
|
|
99
|
+
await this.bus.publish(CLOSE_TOPIC, { ...notice, from: this.podID } satisfies CloseNotice)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async onClose(handler: (notice: CloseNotice) => void): Promise<void> {
|
|
103
|
+
this.unsubscribes.push(
|
|
104
|
+
await this.bus.subscribe(CLOSE_TOPIC, (message) => {
|
|
105
|
+
const notice = message as CloseNotice
|
|
106
|
+
if ((notice?.kind === "machine" || notice?.kind === "launch") && typeof notice.id === "string") handler(notice)
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async stop(): Promise<void> {
|
|
112
|
+
await Promise.allSettled(this.unsubscribes.splice(0).map((unsubscribe) => unsubscribe()))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isCall(call: RpcCall | undefined): call is RpcCall {
|
|
117
|
+
return (
|
|
118
|
+
(call?.kind === "spoke" || call?.kind === "node") &&
|
|
119
|
+
typeof call.connID === "string" &&
|
|
120
|
+
typeof call.tenant === "string" &&
|
|
121
|
+
typeof (call.req as { op?: unknown } | undefined)?.op === "string"
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const offline = (call: RpcCall): Error => (call.kind === "node" ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected"))
|
|
126
|
+
const timeout = (call: RpcCall): Error => (call.kind === "node" ? new RequestTimeoutError(call.req.op, "The node") : new RequestTimeoutError(call.req.op))
|
|
127
|
+
const failed = (call: RpcCall, message: string): Error => (call.kind === "node" ? new NodeError(message) : new SpokeError(message))
|
|
128
|
+
|
|
129
|
+
/** The serving pod's error as the class the caller would have got locally. */
|
|
130
|
+
function revive(call: RpcCall, wire: WireError): Error {
|
|
131
|
+
let error: Error
|
|
132
|
+
switch (wire.name) {
|
|
133
|
+
case "RequestTimeoutError":
|
|
134
|
+
error = new RequestTimeoutError(call.req.op)
|
|
135
|
+
break
|
|
136
|
+
case "RequestTooLargeError":
|
|
137
|
+
error = new RequestTooLargeError(call.req.op)
|
|
138
|
+
break
|
|
139
|
+
case "SpokeError":
|
|
140
|
+
error = new SpokeError(wire.message)
|
|
141
|
+
break
|
|
142
|
+
case "NodeError":
|
|
143
|
+
error = new NodeError(wire.message)
|
|
144
|
+
break
|
|
145
|
+
default:
|
|
146
|
+
error = failed(call, wire.message)
|
|
147
|
+
}
|
|
148
|
+
error.message = wire.message
|
|
149
|
+
return error
|
|
150
|
+
}
|