@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,168 @@
|
|
|
1
|
+
// EventLog on Valkey Streams (design §9): one stream per user, so Last-Event-ID resumes on any pod.
|
|
2
|
+
//
|
|
3
|
+
// Ids are `<epoch>.<n>` like the in-memory log's: n counts per user from 1 and is the entry's stream id (`n-0`,
|
|
4
|
+
// given explicitly rather than by the server clock), so a gap is exact: the entry after the client's id must
|
|
5
|
+
// still be there. The epoch lives in its own key next to the stream and is made the first time a user's log is
|
|
6
|
+
// touched; it goes when the log expires (both keys carry a ttl, renewed on every append and latest), so an id
|
|
7
|
+
// from an earlier log is never mistaken for a current one. Making an epoch deletes any stream left behind, so
|
|
8
|
+
// the stream always outlives its epoch (its ttl is a little longer) and never replays old entries.
|
|
9
|
+
//
|
|
10
|
+
// Bounds: XADD MAXLEN ~ max (approximate: Valkey trims whole nodes, so a log keeps at least `max` events) and,
|
|
11
|
+
// on read, `maxAgeMs` against the `now` clock. since() replays the entries after an id (XRANGE, exclusive) and
|
|
12
|
+
// asks for a resync when it cannot cover the gap: an unknown, another or an expired epoch, an id it never gave
|
|
13
|
+
// out, or the next entry trimmed or older than maxAgeMs.
|
|
14
|
+
import { RedisClient } from "bun"
|
|
15
|
+
import { EVENT_LOG_AGE_MS, EVENT_LOG_MAX } from "../events-log"
|
|
16
|
+
import type { Clock, EventLog, EventMessage, LogEvent, SinceResult } from "./types"
|
|
17
|
+
|
|
18
|
+
export interface ValkeyEventLogOptions {
|
|
19
|
+
readonly now?: Clock
|
|
20
|
+
/** Events kept per user, at least (default EVENT_LOG_MAX). */
|
|
21
|
+
readonly max?: number
|
|
22
|
+
/** Events older than this are not replayed (default EVENT_LOG_AGE_MS). */
|
|
23
|
+
readonly maxAgeMs?: number
|
|
24
|
+
/** Key prefix (default "hub:"). */
|
|
25
|
+
readonly prefix?: string
|
|
26
|
+
/** How long an idle user's log is kept in Valkey, server time (default maxAgeMs + 60 s). */
|
|
27
|
+
readonly ttlMs?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where a user's log lives. Both keys share a hash tag, so a script may use them together in a Valkey cluster. */
|
|
31
|
+
export function valkeyEventKeys(userID: string, prefix = "hub:"): { stream: string; epoch: string } {
|
|
32
|
+
const base = `${prefix}ev:{${encodeURIComponent(userID)}}`
|
|
33
|
+
return { stream: base, epoch: `${base}:epoch` }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The stream outlives its epoch by this much (see above). */
|
|
37
|
+
const STREAM_TTL_MARGIN_MS = 1000
|
|
38
|
+
|
|
39
|
+
// KEYS[1] stream, KEYS[2] epoch; ARGV[1] a new epoch (used when there is none), ARGV[2] ttl ms.
|
|
40
|
+
const OPEN = `
|
|
41
|
+
local epoch = redis.call('GET', KEYS[2])
|
|
42
|
+
if not epoch then
|
|
43
|
+
epoch = ARGV[1]
|
|
44
|
+
redis.call('DEL', KEYS[1])
|
|
45
|
+
end
|
|
46
|
+
redis.call('SET', KEYS[2], epoch, 'PX', ARGV[2])
|
|
47
|
+
local function renew()
|
|
48
|
+
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]) + ${STREAM_TTL_MARGIN_MS})
|
|
49
|
+
end
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
// The n of the user's last entry (0: none yet); trimming keeps the stream's last id.
|
|
53
|
+
const LAST = `
|
|
54
|
+
local function lastN(key)
|
|
55
|
+
if redis.call('EXISTS', key) == 0 then return 0 end
|
|
56
|
+
local r = redis.call('XINFO', 'STREAM', key)
|
|
57
|
+
for i = 1, #r, 2 do
|
|
58
|
+
if r[i] == 'last-generated-id' then return tonumber(string.match(r[i + 1], '^(%d+)')) end
|
|
59
|
+
end
|
|
60
|
+
return 0
|
|
61
|
+
end
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
// ARGV[3] max, ARGV[4] message JSON, ARGV[5] at, ARGV[6] exceptDevice ('' for none).
|
|
65
|
+
const APPEND = `${OPEN}${LAST}
|
|
66
|
+
local n = string.format('%d', lastN(KEYS[1]) + 1)
|
|
67
|
+
if ARGV[6] ~= '' then
|
|
68
|
+
redis.call('XADD', KEYS[1], 'MAXLEN', '~', ARGV[3], n .. '-0', 'm', ARGV[4], 'at', ARGV[5], 'x', ARGV[6])
|
|
69
|
+
else
|
|
70
|
+
redis.call('XADD', KEYS[1], 'MAXLEN', '~', ARGV[3], n .. '-0', 'm', ARGV[4], 'at', ARGV[5])
|
|
71
|
+
end
|
|
72
|
+
renew()
|
|
73
|
+
return {epoch, n}
|
|
74
|
+
`
|
|
75
|
+
|
|
76
|
+
const LATEST = `${OPEN}${LAST}
|
|
77
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then renew() end
|
|
78
|
+
return {epoch, string.format('%d', lastN(KEYS[1]))}
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
// ARGV[1] the n the client holds. Returns {} when the user has no log.
|
|
82
|
+
const SINCE = `${LAST}
|
|
83
|
+
local epoch = redis.call('GET', KEYS[2])
|
|
84
|
+
if not epoch then return {} end
|
|
85
|
+
return {epoch, string.format('%d', lastN(KEYS[1])), redis.call('XRANGE', KEYS[1], '(' .. ARGV[1] .. '-0', '+')}
|
|
86
|
+
`
|
|
87
|
+
|
|
88
|
+
const ID = /^([a-z0-9]+)\.(\d+)$/
|
|
89
|
+
|
|
90
|
+
const newEpoch = () => `${Date.now().toString(36)}${crypto.randomUUID().replaceAll("-", "").slice(0, 8)}`
|
|
91
|
+
|
|
92
|
+
type RawEntry = [string, string[]]
|
|
93
|
+
|
|
94
|
+
export class ValkeyEventLog implements EventLog {
|
|
95
|
+
private readonly client: RedisClient
|
|
96
|
+
private readonly ownsClient: boolean
|
|
97
|
+
private readonly now: Clock
|
|
98
|
+
private readonly max: number
|
|
99
|
+
private readonly maxAgeMs: number
|
|
100
|
+
private readonly prefix: string
|
|
101
|
+
private readonly ttlMs: number
|
|
102
|
+
|
|
103
|
+
/** On a url (its own connection, made on first use) or a client the caller owns. */
|
|
104
|
+
constructor(valkey: string | RedisClient, options: ValkeyEventLogOptions = {}) {
|
|
105
|
+
this.ownsClient = typeof valkey === "string"
|
|
106
|
+
this.client = typeof valkey === "string" ? new RedisClient(valkey) : valkey
|
|
107
|
+
this.now = options.now ?? Date.now
|
|
108
|
+
this.max = options.max ?? EVENT_LOG_MAX
|
|
109
|
+
this.maxAgeMs = options.maxAgeMs ?? EVENT_LOG_AGE_MS
|
|
110
|
+
this.prefix = options.prefix ?? "hub:"
|
|
111
|
+
this.ttlMs = options.ttlMs ?? this.maxAgeMs + 60_000
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async append(userID: string, message: EventMessage, options: { readonly exceptDevice?: string } = {}): Promise<string> {
|
|
115
|
+
const [epoch, n] = (await this.eval(APPEND, userID, [
|
|
116
|
+
newEpoch(),
|
|
117
|
+
String(this.ttlMs),
|
|
118
|
+
String(this.max),
|
|
119
|
+
JSON.stringify(message),
|
|
120
|
+
String(this.now()),
|
|
121
|
+
options.exceptDevice ?? "",
|
|
122
|
+
])) as [string, string]
|
|
123
|
+
return `${epoch}.${n}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async latest(userID: string): Promise<string> {
|
|
127
|
+
const [epoch, n] = (await this.eval(LATEST, userID, [newEpoch(), String(this.ttlMs)])) as [string, string]
|
|
128
|
+
return `${epoch}.${n}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async since(userID: string, lastID: string): Promise<SinceResult> {
|
|
132
|
+
const resync = { resync: true } as const
|
|
133
|
+
const match = ID.exec(lastID)
|
|
134
|
+
const held = Number(match?.[2])
|
|
135
|
+
if (!match || !Number.isSafeInteger(held)) return resync
|
|
136
|
+
const reply = (await this.eval(SINCE, userID, [String(held)])) as [] | [string, string, RawEntry[]]
|
|
137
|
+
if (reply.length === 0) return resync
|
|
138
|
+
const [epoch, last, entries] = reply
|
|
139
|
+
if (epoch !== match[1] || held > Number(last)) return resync
|
|
140
|
+
const events = entries.map(([id, fields]) => this.event(epoch, id, fields))
|
|
141
|
+
if (held === Number(last)) return { events: [] }
|
|
142
|
+
// The next event the client needs (held + 1) must still be there, and not too old.
|
|
143
|
+
const next = events[0]
|
|
144
|
+
if (!next || next.id !== `${epoch}.${held + 1}` || next.at < this.now() - this.maxAgeMs) return resync
|
|
145
|
+
return { events }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Closes the connection when the log made it. */
|
|
149
|
+
close(): void {
|
|
150
|
+
if (this.ownsClient) this.client.close()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private eval(script: string, userID: string, args: string[]): Promise<unknown> {
|
|
154
|
+
const keys = valkeyEventKeys(userID, this.prefix)
|
|
155
|
+
return this.client.send("EVAL", [script, "2", keys.stream, keys.epoch, ...args])
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private event(epoch: string, id: string, fields: string[]): LogEvent {
|
|
159
|
+
const values: Record<string, string> = {}
|
|
160
|
+
for (let i = 0; i + 1 < fields.length; i += 2) values[fields[i]!] = fields[i + 1]!
|
|
161
|
+
return {
|
|
162
|
+
id: `${epoch}.${id.slice(0, id.indexOf("-"))}`,
|
|
163
|
+
message: JSON.parse(values.m ?? "{}") as EventMessage,
|
|
164
|
+
at: Number(values.at),
|
|
165
|
+
...(values.x ? { exceptDevice: values.x } : {}),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Kv in Valkey: SET [PX] [NX], GET, GETDEL and DEL under a key prefix (default "kv:"). Expiry is server-side,
|
|
2
|
+
// so it follows the server's clock, not an injected one.
|
|
3
|
+
import type { Kv, KvSetOptions } from "./types"
|
|
4
|
+
import { ValkeyConnection, type ValkeySource } from "./valkey-client"
|
|
5
|
+
|
|
6
|
+
export interface ValkeyKvOptions {
|
|
7
|
+
/** Prepended to every key (default "kv:"). */
|
|
8
|
+
readonly prefix?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ValkeyKv implements Kv {
|
|
12
|
+
private readonly valkey: ValkeyConnection
|
|
13
|
+
private readonly prefix: string
|
|
14
|
+
|
|
15
|
+
constructor(source: ValkeySource, options: ValkeyKvOptions = {}) {
|
|
16
|
+
this.valkey = new ValkeyConnection(source)
|
|
17
|
+
this.prefix = options.prefix ?? "kv:"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async set(key: string, value: string, options: KvSetOptions = {}): Promise<boolean> {
|
|
21
|
+
const { px, nx } = options
|
|
22
|
+
if (px !== undefined && !(Number.isInteger(px) && px > 0)) throw new Error("Kv.set: px must be a positive whole number of milliseconds")
|
|
23
|
+
const args = [this.prefix + key, value]
|
|
24
|
+
if (px !== undefined) args.push("PX", String(px))
|
|
25
|
+
if (nx) args.push("NX")
|
|
26
|
+
return (await this.valkey.send("SET", args)) === "OK"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get(key: string): Promise<string | undefined> {
|
|
30
|
+
return text(await this.valkey.send("GET", [this.prefix + key]))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getdel(key: string): Promise<string | undefined> {
|
|
34
|
+
return text(await this.valkey.send("GETDEL", [this.prefix + key]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async del(key: string): Promise<boolean> {
|
|
38
|
+
return Number(await this.valkey.send("DEL", [this.prefix + key])) > 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Readiness: PING (rejects when the server does not answer). */
|
|
42
|
+
async ping(): Promise<void> {
|
|
43
|
+
await this.valkey.send("PING", [])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Closes the connection when it was opened from a url. */
|
|
47
|
+
async close(): Promise<void> {
|
|
48
|
+
this.valkey.close()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function text(reply: unknown): string | undefined {
|
|
53
|
+
return reply === null || reply === undefined ? undefined : String(reply)
|
|
54
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Locks in Valkey: a lease is one key "lock:<key>" set with PX (server-side expiry) holding
|
|
2
|
+
// "<fence> <expiresAt> <owner>"; a new holder takes the next value of the counter "fence:<key>", which never
|
|
3
|
+
// expires, so fences keep growing across expiries and releases. Taking (SET PX only when the key is absent or
|
|
4
|
+
// already the caller's), renewing and releasing compare the owner inside Lua scripts, so each is atomic.
|
|
5
|
+
//
|
|
6
|
+
// Expiry follows the server's clock; `expiresAt` in a Lease is stamped by the injected clock (`now`) and is
|
|
7
|
+
// informational. Fence counters stay after their lock goes away (one small key per lock name ever used).
|
|
8
|
+
import type { Clock, Lease, Locks } from "./types"
|
|
9
|
+
import { ValkeyConnection, type ValkeySource } from "./valkey-client"
|
|
10
|
+
|
|
11
|
+
/** KEYS: lock, fence. ARGV: owner, ttl, expiresAt, mode ("acquire" or "renew"). Returns the lease value or nil. */
|
|
12
|
+
const TAKE = `local cur = redis.call('GET', KEYS[1])
|
|
13
|
+
local fence
|
|
14
|
+
if cur then
|
|
15
|
+
local f, _, owner = string.match(cur, '^(%d+) (%S+) (.*)$')
|
|
16
|
+
if owner ~= ARGV[1] then return false end
|
|
17
|
+
fence = f
|
|
18
|
+
elseif ARGV[4] == 'acquire' then
|
|
19
|
+
fence = tostring(redis.call('INCR', KEYS[2]))
|
|
20
|
+
else
|
|
21
|
+
return false
|
|
22
|
+
end
|
|
23
|
+
local value = fence .. ' ' .. ARGV[3] .. ' ' .. ARGV[1]
|
|
24
|
+
redis.call('SET', KEYS[1], value, 'PX', ARGV[2])
|
|
25
|
+
return value`
|
|
26
|
+
|
|
27
|
+
/** KEYS: lock. ARGV: owner. Deletes the lock when the owner holds it; returns 1 or 0. */
|
|
28
|
+
const RELEASE = `local cur = redis.call('GET', KEYS[1])
|
|
29
|
+
if not cur then return 0 end
|
|
30
|
+
local _, _, owner = string.match(cur, '^(%d+) (%S+) (.*)$')
|
|
31
|
+
if owner ~= ARGV[1] then return 0 end
|
|
32
|
+
redis.call('DEL', KEYS[1])
|
|
33
|
+
return 1`
|
|
34
|
+
|
|
35
|
+
export interface ValkeyLocksOptions {
|
|
36
|
+
/** Stamps Lease.expiresAt (default Date.now). Expiry itself is server-side. */
|
|
37
|
+
readonly now?: Clock
|
|
38
|
+
/** Prepended to the lock keys (default "lock:"); fence counters use `fencePrefix` (default "fence:"). */
|
|
39
|
+
readonly prefix?: string
|
|
40
|
+
readonly fencePrefix?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ValkeyLocks implements Locks {
|
|
44
|
+
private readonly valkey: ValkeyConnection
|
|
45
|
+
private readonly now: Clock
|
|
46
|
+
private readonly prefix: string
|
|
47
|
+
private readonly fencePrefix: string
|
|
48
|
+
|
|
49
|
+
constructor(source: ValkeySource, options: ValkeyLocksOptions = {}) {
|
|
50
|
+
this.valkey = new ValkeyConnection(source)
|
|
51
|
+
this.now = options.now ?? Date.now
|
|
52
|
+
this.prefix = options.prefix ?? "lock:"
|
|
53
|
+
this.fencePrefix = options.fencePrefix ?? "fence:"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
acquire(key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
|
|
57
|
+
return this.take("acquire", key, owner, ttlMs)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
renew(key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
|
|
61
|
+
return this.take("renew", key, owner, ttlMs)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async release(key: string, owner: string): Promise<boolean> {
|
|
65
|
+
return Number(await this.valkey.send("EVAL", [RELEASE, "1", this.prefix + key, owner])) === 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async holder(key: string): Promise<Lease | undefined> {
|
|
69
|
+
return parseLease(key, await this.valkey.send("GET", [this.prefix + key]))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Closes the connection when it was opened from a url. */
|
|
73
|
+
async close(): Promise<void> {
|
|
74
|
+
this.valkey.close()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async take(mode: "acquire" | "renew", key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
|
|
78
|
+
if (!(Number.isInteger(ttlMs) && ttlMs > 0)) throw new Error(`Locks.${mode}: ttl must be a positive whole number of milliseconds`)
|
|
79
|
+
const expiresAt = this.now() + ttlMs
|
|
80
|
+
const keys = [this.prefix + key, this.fencePrefix + key]
|
|
81
|
+
return parseLease(key, await this.valkey.send("EVAL", [TAKE, "2", ...keys, owner, String(ttlMs), String(expiresAt), mode]))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A lease from its stored "<fence> <expiresAt> <owner>" (the owner may hold any character). */
|
|
86
|
+
function parseLease(key: string, reply: unknown): Lease | undefined {
|
|
87
|
+
if (reply === null || reply === undefined) return undefined
|
|
88
|
+
const value = String(reply)
|
|
89
|
+
const first = value.indexOf(" ")
|
|
90
|
+
const second = value.indexOf(" ", first + 1)
|
|
91
|
+
return { key, owner: value.slice(second + 1), fence: Number(value.slice(0, first)), expiresAt: Number(value.slice(first + 1, second)) }
|
|
92
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The connection directory (design §9, decision vkxkqiuf §5): which pod holds each spoke or node connection, in the
|
|
2
|
+
// shared Kv so any pod can route an op to it (./rpc.ts). Each pod is the single writer of its own connections:
|
|
3
|
+
// dir:<connID> → {"pod","machineID"} written when the hello completes, removed when the socket closes
|
|
4
|
+
// pod:<podID> → "1" the pod's liveness
|
|
5
|
+
// Both expire after DIRECTORY_TTL_MS and are refreshed every DIRECTORY_HEARTBEAT_MS, so a pod that dies without
|
|
6
|
+
// cleaning up drops out within the TTL. A lookup that finds an entry of a pod whose liveness key is gone removes it
|
|
7
|
+
// on the spot (the dead-pod sweep); callers also forget an entry when its pod has no RPC responder. Connection IDs
|
|
8
|
+
// are never reused, so the plain DEL on close cannot remove another pod's entry.
|
|
9
|
+
import { realTimers } from "../backend/memory-bus"
|
|
10
|
+
import type { Kv, Timers } from "../backend/types"
|
|
11
|
+
|
|
12
|
+
export const DIRECTORY_HEARTBEAT_MS = 15_000
|
|
13
|
+
export const DIRECTORY_TTL_MS = 45_000
|
|
14
|
+
|
|
15
|
+
const ENTRY = "dir:"
|
|
16
|
+
const POD = "pod:"
|
|
17
|
+
|
|
18
|
+
/** Where a connection lives. */
|
|
19
|
+
export interface DirectoryEntry {
|
|
20
|
+
readonly pod: string
|
|
21
|
+
readonly machineID: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConnDirectoryOptions {
|
|
25
|
+
readonly kv: Kv
|
|
26
|
+
/** This pod (HUB_POD_ID). */
|
|
27
|
+
readonly podID: string
|
|
28
|
+
readonly timers?: Timers
|
|
29
|
+
readonly heartbeatMs?: number
|
|
30
|
+
readonly ttlMs?: number
|
|
31
|
+
/** Called when a Kv write fails (default: logged). The directory keeps going; the next heartbeat retries. */
|
|
32
|
+
readonly onError?: (error: unknown) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ConnDirectory {
|
|
36
|
+
readonly podID: string
|
|
37
|
+
private readonly kv: Kv
|
|
38
|
+
private readonly timers: Timers
|
|
39
|
+
private readonly heartbeatMs: number
|
|
40
|
+
private readonly ttlMs: number
|
|
41
|
+
private readonly onError: (error: unknown) => void
|
|
42
|
+
/** This pod's connections: connID → machineID. */
|
|
43
|
+
private readonly own = new Map<string, string>()
|
|
44
|
+
private timer: unknown
|
|
45
|
+
private running = false
|
|
46
|
+
|
|
47
|
+
constructor(options: ConnDirectoryOptions) {
|
|
48
|
+
this.kv = options.kv
|
|
49
|
+
this.podID = options.podID
|
|
50
|
+
this.timers = options.timers ?? realTimers
|
|
51
|
+
this.heartbeatMs = options.heartbeatMs ?? DIRECTORY_HEARTBEAT_MS
|
|
52
|
+
this.ttlMs = options.ttlMs ?? DIRECTORY_TTL_MS
|
|
53
|
+
this.onError = options.onError ?? ((error) => console.error(`[hub] directory: ${error instanceof Error ? error.message : String(error)}`))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Marks the pod alive and starts the heartbeat. */
|
|
57
|
+
async start(): Promise<void> {
|
|
58
|
+
if (this.running) return
|
|
59
|
+
this.running = true
|
|
60
|
+
await this.kv.set(POD + this.podID, "1", { px: this.ttlMs })
|
|
61
|
+
this.arm()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stops the heartbeat and removes the pod's entries and liveness key (a clean shutdown). */
|
|
65
|
+
async stop(): Promise<void> {
|
|
66
|
+
this.running = false
|
|
67
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
68
|
+
this.timer = undefined
|
|
69
|
+
const conns = [...this.own.keys()]
|
|
70
|
+
this.own.clear()
|
|
71
|
+
await Promise.all([...conns.map((connID) => this.kv.del(ENTRY + connID)), this.kv.del(POD + this.podID)])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Records a connection this pod holds. */
|
|
75
|
+
async register(connID: string, machineID: string): Promise<void> {
|
|
76
|
+
this.own.set(connID, machineID)
|
|
77
|
+
await this.write(connID, machineID)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Removes a connection this pod held (its socket closed). */
|
|
81
|
+
async unregister(connID: string): Promise<void> {
|
|
82
|
+
if (!this.own.delete(connID)) return
|
|
83
|
+
await this.kv.del(ENTRY + connID)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where a connection lives; undefined when unknown, expired, or its pod is dead (the entry is then removed). */
|
|
87
|
+
async lookup(connID: string): Promise<DirectoryEntry | undefined> {
|
|
88
|
+
const entry = parse(await this.kv.get(ENTRY + connID))
|
|
89
|
+
if (!entry) return undefined
|
|
90
|
+
if (entry.pod !== this.podID && !(await this.podAlive(entry.pod))) {
|
|
91
|
+
await this.forget(connID, entry.pod)
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
return entry
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Removes a connection's entry if it still names `pod` (that pod is gone). */
|
|
98
|
+
async forget(connID: string, pod: string): Promise<void> {
|
|
99
|
+
const entry = parse(await this.kv.get(ENTRY + connID))
|
|
100
|
+
if (entry?.pod === pod) await this.kv.del(ENTRY + connID)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async podAlive(pod: string): Promise<boolean> {
|
|
104
|
+
return (await this.kv.get(POD + pod)) !== undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** This pod's registered connections. */
|
|
108
|
+
local(): string[] {
|
|
109
|
+
return [...this.own.keys()]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private write(connID: string, machineID: string): Promise<boolean> {
|
|
113
|
+
return this.kv.set(ENTRY + connID, JSON.stringify({ pod: this.podID, machineID } satisfies DirectoryEntry), { px: this.ttlMs })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private arm(): void {
|
|
117
|
+
this.timer = this.timers.set(() => void this.beat(), this.heartbeatMs)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Refreshes the pod's liveness and every entry it holds, then re-arms. */
|
|
121
|
+
private async beat(): Promise<void> {
|
|
122
|
+
if (!this.running) return
|
|
123
|
+
try {
|
|
124
|
+
await this.kv.set(POD + this.podID, "1", { px: this.ttlMs })
|
|
125
|
+
await Promise.all([...this.own].map(([connID, machineID]) => this.write(connID, machineID)))
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.onError(error)
|
|
128
|
+
}
|
|
129
|
+
if (this.running) this.arm()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parse(raw: string | undefined): DirectoryEntry | undefined {
|
|
134
|
+
if (raw === undefined) return undefined
|
|
135
|
+
try {
|
|
136
|
+
const value = JSON.parse(raw) as Partial<DirectoryEntry>
|
|
137
|
+
return typeof value?.pod === "string" && typeof value.machineID === "string" ? { pod: value.pod, machineID: value.machineID } : undefined
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// A per-user feed of items (stored agent messages, launch changes) that a cluster shares over the Bus (design §9,
|
|
2
|
+
// decision vkxkqiuf §6). publish() runs this pod's listeners at once and, once attached, sends the item to the other
|
|
3
|
+
// pods, whose listeners run when it arrives (at most once). A listener subscribed with `origin: true` runs only for
|
|
4
|
+
// items published on its own pod: work that must happen once per item (the console broadcast, which the live
|
|
5
|
+
// fan-out already sends to every pod; webhooks; closing a launch's connections, which pod RPC already reaches).
|
|
6
|
+
// Unattached (one process), every listener runs for every item, as before.
|
|
7
|
+
import type { Bus, Unsubscribe } from "../backend/types"
|
|
8
|
+
|
|
9
|
+
export type FeedListener<T> = (userID: string, item: T) => void
|
|
10
|
+
|
|
11
|
+
export interface FeedSubscribeOptions {
|
|
12
|
+
/** Only items published on this pod. */
|
|
13
|
+
readonly origin?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FeedAttachOptions {
|
|
17
|
+
readonly bus: Bus
|
|
18
|
+
/** This pod (HUB_POD_ID). */
|
|
19
|
+
readonly podID: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Envelope<T> {
|
|
23
|
+
readonly pod: string
|
|
24
|
+
readonly userID: string
|
|
25
|
+
readonly item: T
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Feed<T> {
|
|
29
|
+
private readonly listeners = new Map<FeedListener<T>, boolean>()
|
|
30
|
+
private attached?: { readonly bus: Bus; readonly podID: string; readonly unsubscribe: Promise<Unsubscribe> }
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
/** The Bus topic. */
|
|
34
|
+
private readonly topic: string,
|
|
35
|
+
/** Logs a throwing listener (never stops the others). */
|
|
36
|
+
private readonly report: (cause: unknown) => void,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
subscribe(listener: FeedListener<T>, options: FeedSubscribeOptions = {}): () => void {
|
|
40
|
+
// Wrapped so subscribing one function twice gives two independent subscriptions.
|
|
41
|
+
const own: FeedListener<T> = (userID, item) => listener(userID, item)
|
|
42
|
+
this.listeners.set(own, options.origin === true)
|
|
43
|
+
return () => void this.listeners.delete(own)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
publish(userID: string, item: T): void {
|
|
47
|
+
this.run(userID, item, true)
|
|
48
|
+
const attached = this.attached
|
|
49
|
+
if (attached) {
|
|
50
|
+
attached.bus.publish(this.topic, { pod: attached.podID, userID, item } satisfies Envelope<T>).catch((cause: unknown) => this.report(cause))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Shares the feed with the other pods on the Bus. */
|
|
55
|
+
async attach(options: FeedAttachOptions): Promise<void> {
|
|
56
|
+
if (this.attached) return
|
|
57
|
+
const unsubscribe = options.bus.subscribe(this.topic, (raw) => {
|
|
58
|
+
const envelope = raw as Envelope<T> | null
|
|
59
|
+
if (typeof envelope !== "object" || envelope === null || typeof envelope.userID !== "string") return
|
|
60
|
+
if (envelope.pod === options.podID) return
|
|
61
|
+
this.run(envelope.userID, envelope.item, false)
|
|
62
|
+
})
|
|
63
|
+
this.attached = { ...options, unsubscribe }
|
|
64
|
+
await unsubscribe
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stops sharing; the Bus subscription is dropped once it is made (a Bus that never connected must not hold up a stop). */
|
|
68
|
+
async detach(): Promise<void> {
|
|
69
|
+
const attached = this.attached
|
|
70
|
+
this.attached = undefined
|
|
71
|
+
void attached?.unsubscribe.then((off) => off()).catch((cause: unknown) => this.report(cause))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private run(userID: string, item: T, local: boolean): void {
|
|
75
|
+
for (const [listener, origin] of [...this.listeners]) {
|
|
76
|
+
if (origin && !local) continue
|
|
77
|
+
try {
|
|
78
|
+
listener(userID, item)
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
this.report(cause)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|