@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,518 @@
|
|
|
1
|
+
// The Store's shared persistence (cluster mode; decision vkxkqiuf §3, §4): each record is a row in a StateStore
|
|
2
|
+
// (Postgres) under user "_hub", and each pod keeps the whole StateData as a cache for the Store's synchronous reads.
|
|
3
|
+
//
|
|
4
|
+
// Rows (collection/key → value):
|
|
5
|
+
// users/<id>, machines/<id>, devices/<id> the records
|
|
6
|
+
// push/<sha256(endpoint)> push subscriptions
|
|
7
|
+
// settings/vapid | speech | agentsActingPaused hub-wide settings
|
|
8
|
+
// audit/<time>-<seq>-<random> audit entries (sorted by key = by time)
|
|
9
|
+
// machine-tokens/<tokenHash>, device-tokens/<tokenHash> {id}: O(1) token lookups on any pod
|
|
10
|
+
// user-emails/<email> {id}: one user per email, also across pods
|
|
11
|
+
//
|
|
12
|
+
// Mutations run on the cache, then write the rows they touched with the version the cache had (ifVersion); on a
|
|
13
|
+
// conflict the touched rows are re-read and the mutation runs again on fresh data. Records and settings are
|
|
14
|
+
// versioned this way; audit, token and email index rows are inserted with ifVersion 0 (an email row in use means
|
|
15
|
+
// the user exists: the mutation runs again and finds it) and deleted unconditionally. After a write, the pod
|
|
16
|
+
// publishes store.changed {from, rows: [[collection, key, version]]} (never values); other pods re-read those rows.
|
|
17
|
+
// The Bus is at most once (a reconnect loses messages), so the cache is also reloaded every ttlMs, and
|
|
18
|
+
// security-relevant reads (token lookups, epochs, disabled users) re-read their rows first (Store.recheck).
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
20
|
+
import { realTimers } from "./backend/memory-bus"
|
|
21
|
+
import type { Bus, StateStore, Timers, Unsubscribe } from "./backend/types"
|
|
22
|
+
import type { AuditEntry, StateData } from "./store"
|
|
23
|
+
|
|
24
|
+
/** The StateStore "user" the Store's rows live under. */
|
|
25
|
+
export const HUB_ROWS = "_hub"
|
|
26
|
+
/** Bus topic of the Store's row changes. */
|
|
27
|
+
export const STORE_CHANGED_TOPIC = "store.changed"
|
|
28
|
+
/** How often a pod reloads its whole cache (covers store.changed messages lost on a Bus reconnect). */
|
|
29
|
+
export const STORE_CACHE_TTL_MS = 30_000
|
|
30
|
+
/** Attempts of a mutation that keeps hitting version conflicts. */
|
|
31
|
+
const MAX_ATTEMPTS = 20
|
|
32
|
+
|
|
33
|
+
export type Collection = "users" | "machines" | "devices" | "push" | "settings" | "audit" | "machine-tokens" | "device-tokens" | "user-emails"
|
|
34
|
+
|
|
35
|
+
const RECORDS = ["users", "machines", "devices", "push", "settings", "audit"] as const
|
|
36
|
+
const SETTINGS = ["vapid", "speech", "agentsActingPaused"] as const
|
|
37
|
+
|
|
38
|
+
export interface RowRef {
|
|
39
|
+
readonly collection: Collection
|
|
40
|
+
readonly key: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const refID = (ref: RowRef) => `${ref.collection}\u0000${ref.key}`
|
|
44
|
+
|
|
45
|
+
/** Versioned records (ifVersion writes) versus insert-only rows (ifVersion 0, unconditional delete). */
|
|
46
|
+
const versioned = (collection: Collection) => collection === "users" || collection === "machines" || collection === "devices" || collection === "push" || collection === "settings"
|
|
47
|
+
|
|
48
|
+
export const pushKey = (endpoint: string): string => createHash("sha256").update(endpoint).digest("hex")
|
|
49
|
+
|
|
50
|
+
/** What a mutation touched (rows to write); the file Store only needs to know whether it is dirty. */
|
|
51
|
+
export class StoreTx {
|
|
52
|
+
readonly refs = new Map<string, RowRef>()
|
|
53
|
+
|
|
54
|
+
touch(collection: Collection, key: string): void {
|
|
55
|
+
this.refs.set(refID({ collection, key }), { collection, key })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get dirty(): boolean {
|
|
59
|
+
return this.refs.size > 0
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A mutation touched rows another pod changed meanwhile, again and again. */
|
|
64
|
+
export class StoreConflictError extends Error {
|
|
65
|
+
constructor() {
|
|
66
|
+
super("The hub state kept changing during this update; try again")
|
|
67
|
+
this.name = "StoreConflictError"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SharedStoreOptions {
|
|
72
|
+
readonly state: StateStore
|
|
73
|
+
readonly bus: Bus
|
|
74
|
+
readonly now?: () => number
|
|
75
|
+
/** The TTL reload timer (injectable for tests; default real, unref'd timers). */
|
|
76
|
+
readonly timers?: Timers
|
|
77
|
+
/** Cache reload interval (default STORE_CACHE_TTL_MS). */
|
|
78
|
+
readonly ttlMs?: number
|
|
79
|
+
/** Names this pod in store.changed (default random); its own messages are ignored. */
|
|
80
|
+
readonly instanceID?: string
|
|
81
|
+
readonly warn?: (message: string) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Write {
|
|
85
|
+
readonly ref: RowRef
|
|
86
|
+
readonly value: unknown
|
|
87
|
+
readonly expected: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ChangedMessage {
|
|
91
|
+
readonly from: string
|
|
92
|
+
readonly rows: [string, string, number][]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const emptyState = (): StateData => ({ machines: [], devices: [], push: [], users: [], audit: [] })
|
|
96
|
+
|
|
97
|
+
const byCreated = (a: { created: number; id: string }, b: { created: number; id: string }) =>
|
|
98
|
+
a.created - b.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
99
|
+
|
|
100
|
+
export class SharedRows {
|
|
101
|
+
readonly instanceID: string
|
|
102
|
+
private readonly store: StateStore
|
|
103
|
+
private readonly bus: Bus
|
|
104
|
+
private readonly timers: Timers
|
|
105
|
+
private readonly ttlMs: number
|
|
106
|
+
private readonly warn: (message: string) => void
|
|
107
|
+
/** refID → the version the cache holds (versioned rows only). */
|
|
108
|
+
private versions = new Map<string, number>()
|
|
109
|
+
/** Audit entry → its row key. */
|
|
110
|
+
private readonly auditKeys = new WeakMap<AuditEntry, string>()
|
|
111
|
+
private loading?: Promise<void>
|
|
112
|
+
/** The first load finished: mutations run their first attempt synchronously. */
|
|
113
|
+
private loaded = false
|
|
114
|
+
private timer?: unknown
|
|
115
|
+
private unsubscribe?: Unsubscribe
|
|
116
|
+
private closed = false
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
options: SharedStoreOptions,
|
|
120
|
+
private readonly cache: { get(): StateData; set(state: StateData): void },
|
|
121
|
+
) {
|
|
122
|
+
this.store = options.state
|
|
123
|
+
this.bus = options.bus
|
|
124
|
+
this.timers = options.timers ?? realTimers
|
|
125
|
+
this.ttlMs = options.ttlMs ?? STORE_CACHE_TTL_MS
|
|
126
|
+
this.warn = options.warn ?? ((message) => console.warn(message))
|
|
127
|
+
this.instanceID = options.instanceID ?? randomBytes(8).toString("hex")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static emptyState = emptyState
|
|
131
|
+
|
|
132
|
+
/** Subscribes to store.changed and loads the cache, once (a failure is retried by the next call). */
|
|
133
|
+
ready(): Promise<void> {
|
|
134
|
+
this.loading ??= this.start().catch((error: unknown) => {
|
|
135
|
+
this.loading = undefined
|
|
136
|
+
throw error
|
|
137
|
+
})
|
|
138
|
+
return this.loading
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async close(): Promise<void> {
|
|
142
|
+
this.closed = true
|
|
143
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
144
|
+
this.timer = undefined
|
|
145
|
+
const unsubscribe = this.unsubscribe
|
|
146
|
+
this.unsubscribe = undefined
|
|
147
|
+
await unsubscribe?.()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async start(): Promise<void> {
|
|
151
|
+
// Subscribed before loading, so no change between the two is missed.
|
|
152
|
+
this.unsubscribe ??= await this.bus.subscribe(STORE_CHANGED_TOPIC, (message) => this.onChanged(message))
|
|
153
|
+
await this.load()
|
|
154
|
+
this.loaded = true
|
|
155
|
+
this.schedule()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private schedule(): void {
|
|
159
|
+
if (this.closed) return
|
|
160
|
+
this.timer = this.timers.set(() => {
|
|
161
|
+
this.timer = undefined
|
|
162
|
+
this.load()
|
|
163
|
+
.catch((error: unknown) => this.warn(`[hub] state cache reload failed: ${(error as Error)?.message ?? error}`))
|
|
164
|
+
.finally(() => this.schedule())
|
|
165
|
+
}, this.ttlMs)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Reads every record row into a new cache. */
|
|
169
|
+
async load(): Promise<void> {
|
|
170
|
+
const [users, machines, devices, push, settings, audit] = await Promise.all(RECORDS.map((collection) => this.store.list(HUB_ROWS, collection)))
|
|
171
|
+
const versions = new Map<string, number>()
|
|
172
|
+
const note = (collection: Collection, rows: { key: string; version: number }[]) => {
|
|
173
|
+
for (const row of rows) versions.set(refID({ collection, key: row.key }), row.version)
|
|
174
|
+
}
|
|
175
|
+
note("users", users!)
|
|
176
|
+
note("machines", machines!)
|
|
177
|
+
note("devices", devices!)
|
|
178
|
+
note("push", push!)
|
|
179
|
+
note("settings", settings!)
|
|
180
|
+
const state = emptyState()
|
|
181
|
+
state.users = users!.map((row) => row.value as StateData["users"][number]).sort(byCreated)
|
|
182
|
+
state.machines = machines!.map((row) => row.value as StateData["machines"][number]).sort(byCreated)
|
|
183
|
+
state.devices = devices!.map((row) => row.value as StateData["devices"][number]).sort(byCreated)
|
|
184
|
+
state.push = push!
|
|
185
|
+
.map((row) => row.value as StateData["push"][number])
|
|
186
|
+
.sort((a, b) => a.created - b.created || (a.endpoint < b.endpoint ? -1 : 1))
|
|
187
|
+
for (const row of settings!) setSetting(state, row.key, row.value)
|
|
188
|
+
state.audit = audit!.map((row) => {
|
|
189
|
+
const entry = row.value as AuditEntry
|
|
190
|
+
this.auditKeys.set(entry, row.key)
|
|
191
|
+
return entry
|
|
192
|
+
})
|
|
193
|
+
this.versions = versions
|
|
194
|
+
this.cache.set(state)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A new row key for an audit entry (sorted by time). */
|
|
198
|
+
keyAudit(entry: AuditEntry): string {
|
|
199
|
+
// After every key the cache holds, so entries keep their order across pods when clocks tie or skew a little.
|
|
200
|
+
const last = this.cache.get().audit.reduce<string | undefined>((max, e) => {
|
|
201
|
+
const key = this.auditKeys.get(e)
|
|
202
|
+
return key && (!max || key > max) ? key : max
|
|
203
|
+
}, undefined)
|
|
204
|
+
const key = nextAuditKey(last, entry.at, randomBytes(4).toString("hex"))
|
|
205
|
+
this.auditKeys.set(entry, key)
|
|
206
|
+
return key
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Adds the audit rows the cache lacks (entries other pods wrote that store.changed has not brought yet, or never
|
|
211
|
+
* will after a lost message), so the next keyAudit sorts after every entry written before it on any pod.
|
|
212
|
+
*/
|
|
213
|
+
async syncAudit(): Promise<void> {
|
|
214
|
+
await this.ready()
|
|
215
|
+
const rows = await this.store.list<AuditEntry>(HUB_ROWS, "audit")
|
|
216
|
+
const known = new Set(this.cache.get().audit.map((entry) => this.auditKeys.get(entry)))
|
|
217
|
+
for (const row of rows) if (!known.has(row.key)) applyRow(this.cache.get(), { collection: "audit", key: row.key }, row.value, this)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
auditKey(entry: AuditEntry): string | undefined {
|
|
221
|
+
return this.auditKeys.get(entry)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Records the row key of an audit entry read from the database. */
|
|
225
|
+
rememberAudit(entry: AuditEntry, key: string): void {
|
|
226
|
+
this.auditKeys.set(entry, key)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Runs `fn` on the cache and writes what it touched; on a version conflict, re-reads those rows and runs it
|
|
231
|
+
* again. Once loaded, the first run happens synchronously (like the file Store: a caller that does not await
|
|
232
|
+
* still sees its change in the cache at once); the writes follow.
|
|
233
|
+
*/
|
|
234
|
+
mutate<T>(fn: (tx: StoreTx) => T): Promise<T> {
|
|
235
|
+
if (!this.loaded) return this.ready().then(() => this.attempt(fn))
|
|
236
|
+
try {
|
|
237
|
+
return this.attempt(fn)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return Promise.reject(error)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One run of `fn` (synchronous), then its writes and any retries. */
|
|
244
|
+
private attempt<T>(fn: (tx: StoreTx) => T, attempt = 1): Promise<T> {
|
|
245
|
+
const tx = new StoreTx()
|
|
246
|
+
const result = fn(tx)
|
|
247
|
+
if (!tx.dirty) return Promise.resolve(result)
|
|
248
|
+
const writes = [...tx.refs.values()].map((ref) => this.snapshot(ref))
|
|
249
|
+
return this.write(fn, writes, result, attempt)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async write<T>(fn: (tx: StoreTx) => T, writes: readonly Write[], result: T, attempt: number): Promise<T> {
|
|
253
|
+
let ok: boolean
|
|
254
|
+
try {
|
|
255
|
+
ok = await this.commit(writes)
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// The cache holds the change that was not written: put the rows back as they are.
|
|
258
|
+
await Promise.allSettled(writes.map((write) => this.refresh(write.ref)))
|
|
259
|
+
throw error
|
|
260
|
+
}
|
|
261
|
+
if (ok) return result
|
|
262
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
263
|
+
await Promise.allSettled(writes.map((write) => this.refresh(write.ref)))
|
|
264
|
+
throw new StoreConflictError()
|
|
265
|
+
}
|
|
266
|
+
await Promise.all(writes.map((write) => this.refresh(write.ref)))
|
|
267
|
+
return this.attempt(fn, attempt + 1)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private snapshot(ref: RowRef): Write {
|
|
271
|
+
const value = rowOf(this.cache.get(), ref, this)
|
|
272
|
+
return { ref, value: value === undefined ? undefined : structuredClone(value), expected: this.versions.get(refID(ref)) ?? 0 }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Writes the rows in order; false at the first version conflict. Publishes what was written. */
|
|
276
|
+
private async commit(writes: readonly Write[]): Promise<boolean> {
|
|
277
|
+
const changed: [string, string, number][] = []
|
|
278
|
+
let ok = true
|
|
279
|
+
try {
|
|
280
|
+
for (const { ref, value, expected } of writes) {
|
|
281
|
+
const id = refID(ref)
|
|
282
|
+
if (versioned(ref.collection)) {
|
|
283
|
+
if (value === undefined) {
|
|
284
|
+
if (expected === 0) continue
|
|
285
|
+
const result = await this.store.delete(HUB_ROWS, ref.collection, ref.key, { ifVersion: expected })
|
|
286
|
+
if (!result.ok) {
|
|
287
|
+
ok = false
|
|
288
|
+
break
|
|
289
|
+
}
|
|
290
|
+
this.versions.delete(id)
|
|
291
|
+
applyRow(this.cache.get(), ref, undefined, this)
|
|
292
|
+
changed.push([ref.collection, ref.key, 0])
|
|
293
|
+
} else {
|
|
294
|
+
const result = await this.store.put(HUB_ROWS, ref.collection, ref.key, value, { ifVersion: expected })
|
|
295
|
+
if (!result.ok) {
|
|
296
|
+
ok = false
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
this.versions.set(id, result.version)
|
|
300
|
+
// The cache holds what is now stored at that version (a refresh meanwhile may have replaced it).
|
|
301
|
+
applyRow(this.cache.get(), ref, structuredClone(value), this)
|
|
302
|
+
changed.push([ref.collection, ref.key, result.version])
|
|
303
|
+
}
|
|
304
|
+
} else if (value === undefined) {
|
|
305
|
+
await this.store.delete(HUB_ROWS, ref.collection, ref.key)
|
|
306
|
+
changed.push([ref.collection, ref.key, 0])
|
|
307
|
+
} else {
|
|
308
|
+
const result = await this.store.put(HUB_ROWS, ref.collection, ref.key, value, { ifVersion: 0 })
|
|
309
|
+
if (!result.ok) {
|
|
310
|
+
const current = await this.store.get(HUB_ROWS, ref.collection, ref.key)
|
|
311
|
+
// The same row already there (written by an earlier attempt of this mutation) is fine.
|
|
312
|
+
if (JSON.stringify(current?.value) === JSON.stringify(value)) continue
|
|
313
|
+
ok = false
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
changed.push([ref.collection, ref.key, result.version])
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
if (changed.length) await this.publish(changed)
|
|
321
|
+
}
|
|
322
|
+
return ok
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async publish(rows: [string, string, number][]): Promise<void> {
|
|
326
|
+
const message: ChangedMessage = { from: this.instanceID, rows }
|
|
327
|
+
try {
|
|
328
|
+
await this.bus.publish(STORE_CHANGED_TOPIC, message)
|
|
329
|
+
} catch (error) {
|
|
330
|
+
this.warn(`[hub] store.changed not published: ${(error as Error)?.message ?? error}`)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async onChanged(raw: unknown): Promise<void> {
|
|
335
|
+
if (this.closed || !this.loading) return
|
|
336
|
+
const message = raw as Partial<ChangedMessage> | null
|
|
337
|
+
if (!message || typeof message !== "object" || message.from === this.instanceID || !Array.isArray(message.rows)) return
|
|
338
|
+
const stale: RowRef[] = []
|
|
339
|
+
for (const row of message.rows) {
|
|
340
|
+
if (!Array.isArray(row) || typeof row[0] !== "string" || typeof row[1] !== "string" || typeof row[2] !== "number") continue
|
|
341
|
+
const ref = { collection: row[0] as Collection, key: row[1] }
|
|
342
|
+
if (!isCollection(ref.collection)) continue
|
|
343
|
+
if (versioned(ref.collection) && row[2] !== 0 && (this.versions.get(refID(ref)) ?? 0) >= row[2]) continue
|
|
344
|
+
stale.push(ref)
|
|
345
|
+
}
|
|
346
|
+
await Promise.all(stale.map((ref) => this.refresh(ref)))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Re-reads one row into the cache (an index row also re-reads the record it points to). */
|
|
350
|
+
async refresh(ref: RowRef): Promise<void> {
|
|
351
|
+
const row = await this.store.get(HUB_ROWS, ref.collection, ref.key)
|
|
352
|
+
const id = refID(ref)
|
|
353
|
+
if (versioned(ref.collection)) {
|
|
354
|
+
// Same version: the cache has it. Older: read before a newer write this pod already holds.
|
|
355
|
+
if (row && (this.versions.get(id) ?? 0) >= row.version) return
|
|
356
|
+
if (row) this.versions.set(id, row.version)
|
|
357
|
+
else this.versions.delete(id)
|
|
358
|
+
}
|
|
359
|
+
applyRow(this.cache.get(), ref, row?.value, this)
|
|
360
|
+
const target = row && indexTarget(ref.collection)
|
|
361
|
+
if (target) {
|
|
362
|
+
const pointed = (row.value as { id?: unknown }).id
|
|
363
|
+
if (typeof pointed === "string") await this.refresh({ collection: target, key: pointed })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The record id a token-hash index row points to (read from the database). */
|
|
368
|
+
async indexed(collection: "machine-tokens" | "device-tokens", hash: string): Promise<string | undefined> {
|
|
369
|
+
const row = await this.store.get<{ id?: unknown }>(HUB_ROWS, collection, hash)
|
|
370
|
+
return typeof row?.value?.id === "string" ? row.value.id : undefined
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The row key of an audit entry at `at` that sorts after `last` (the greatest key so far):
|
|
376
|
+
* <time, 15 digits>-<sequence within that time, 6 digits>-<suffix: 8 hex>.
|
|
377
|
+
*/
|
|
378
|
+
export function nextAuditKey(last: string | undefined, at: number, suffix: string): string {
|
|
379
|
+
const time0 = Math.max(0, Math.floor(at))
|
|
380
|
+
const [lastAt, lastSeq] = last ? [Number(last.slice(0, 15)), Number(last.slice(16, 22))] : [-1, -1]
|
|
381
|
+
const time = Math.max(time0, lastAt)
|
|
382
|
+
const seq = time === lastAt ? lastSeq + 1 : 0
|
|
383
|
+
return `${String(time).padStart(15, "0")}-${String(seq).padStart(6, "0")}-${suffix}`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface StateRow {
|
|
387
|
+
readonly collection: Collection
|
|
388
|
+
readonly key: string
|
|
389
|
+
readonly value: unknown
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Every row a whole StateData is stored as (records, settings, audit with the given keys, then the index rows),
|
|
394
|
+
* through the same mapping the shared Store writes with (`hub-server import`).
|
|
395
|
+
*/
|
|
396
|
+
export function stateRows(state: StateData, auditKey: (entry: AuditEntry) => string): StateRow[] {
|
|
397
|
+
const refs: RowRef[] = [
|
|
398
|
+
...state.users.map((u) => ({ collection: "users" as const, key: u.id })),
|
|
399
|
+
...state.machines.map((m) => ({ collection: "machines" as const, key: m.id })),
|
|
400
|
+
...state.devices.map((d) => ({ collection: "devices" as const, key: d.id })),
|
|
401
|
+
...state.push.map((p) => ({ collection: "push" as const, key: pushKey(p.endpoint) })),
|
|
402
|
+
...SETTINGS.map((key) => ({ collection: "settings" as const, key })),
|
|
403
|
+
...state.audit.map((entry) => ({ collection: "audit" as const, key: auditKey(entry) })),
|
|
404
|
+
...state.machines.map((m) => ({ collection: "machine-tokens" as const, key: m.tokenHash })),
|
|
405
|
+
...state.devices.map((d) => ({ collection: "device-tokens" as const, key: d.tokenHash })),
|
|
406
|
+
...state.users.flatMap((u) => (u.email ? [{ collection: "user-emails" as const, key: u.email }] : [])),
|
|
407
|
+
]
|
|
408
|
+
const rows: StateRow[] = []
|
|
409
|
+
for (const ref of refs) {
|
|
410
|
+
const value = rowOf(state, ref, { auditKey })
|
|
411
|
+
if (value !== undefined) rows.push({ ...ref, value })
|
|
412
|
+
}
|
|
413
|
+
return rows
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const isCollection = (value: string): value is Collection =>
|
|
417
|
+
(["users", "machines", "devices", "push", "settings", "audit", "machine-tokens", "device-tokens", "user-emails"] as const).includes(value as Collection)
|
|
418
|
+
|
|
419
|
+
function indexTarget(collection: Collection): Collection | undefined {
|
|
420
|
+
if (collection === "machine-tokens") return "machines"
|
|
421
|
+
if (collection === "device-tokens") return "devices"
|
|
422
|
+
if (collection === "user-emails") return "users"
|
|
423
|
+
return undefined
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function setSetting(state: StateData, key: string, value: unknown): void {
|
|
427
|
+
if (key === "vapid") {
|
|
428
|
+
if (value === undefined) delete state.vapid
|
|
429
|
+
else state.vapid = value as NonNullable<StateData["vapid"]>
|
|
430
|
+
} else if (key === "speech") {
|
|
431
|
+
if (value === undefined) delete state.speech
|
|
432
|
+
else state.speech = value as NonNullable<StateData["speech"]>
|
|
433
|
+
} else if (key === "agentsActingPaused") {
|
|
434
|
+
if (value === true) state.agentsActingPaused = true
|
|
435
|
+
else delete state.agentsActingPaused
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The row value the cache holds for `ref` (undefined: the row should not exist). */
|
|
440
|
+
function rowOf(state: StateData, ref: RowRef, rows: Pick<SharedRows, "auditKey">): unknown {
|
|
441
|
+
const { collection, key } = ref
|
|
442
|
+
switch (collection) {
|
|
443
|
+
case "users":
|
|
444
|
+
return state.users.find((u) => u.id === key)
|
|
445
|
+
case "machines":
|
|
446
|
+
return state.machines.find((m) => m.id === key)
|
|
447
|
+
case "devices":
|
|
448
|
+
return state.devices.find((d) => d.id === key)
|
|
449
|
+
case "push":
|
|
450
|
+
return state.push.find((p) => pushKey(p.endpoint) === key)
|
|
451
|
+
case "settings":
|
|
452
|
+
return (SETTINGS as readonly string[]).includes(key) ? (state as unknown as Record<string, unknown>)[key] : undefined
|
|
453
|
+
case "audit":
|
|
454
|
+
return state.audit.find((entry) => rows.auditKey(entry) === key)
|
|
455
|
+
case "machine-tokens": {
|
|
456
|
+
const machine = state.machines.find((m) => m.tokenHash === key)
|
|
457
|
+
return machine && { id: machine.id }
|
|
458
|
+
}
|
|
459
|
+
case "device-tokens": {
|
|
460
|
+
const device = state.devices.find((d) => d.tokenHash === key)
|
|
461
|
+
return device && { id: device.id }
|
|
462
|
+
}
|
|
463
|
+
case "user-emails": {
|
|
464
|
+
const user = state.users.find((u) => u.email === key)
|
|
465
|
+
return user && { id: user.id }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Puts a row's value (undefined: removed) into the cache. Index rows are derived: nothing to do. */
|
|
471
|
+
function applyRow(state: StateData, ref: RowRef, value: unknown, rows: SharedRows): void {
|
|
472
|
+
const { collection, key } = ref
|
|
473
|
+
const replace = <T>(list: T[], match: (item: T) => boolean, sort?: (a: T, b: T) => number): T[] => {
|
|
474
|
+
const index = list.findIndex(match)
|
|
475
|
+
if (value === undefined) {
|
|
476
|
+
if (index >= 0) list.splice(index, 1)
|
|
477
|
+
} else if (index >= 0) list[index] = value as T
|
|
478
|
+
else {
|
|
479
|
+
list.push(value as T)
|
|
480
|
+
if (sort) list.sort(sort)
|
|
481
|
+
}
|
|
482
|
+
return list
|
|
483
|
+
}
|
|
484
|
+
switch (collection) {
|
|
485
|
+
case "users":
|
|
486
|
+
replace(state.users, (u) => u.id === key, byCreated)
|
|
487
|
+
return
|
|
488
|
+
case "machines":
|
|
489
|
+
replace(state.machines, (m) => m.id === key, byCreated)
|
|
490
|
+
return
|
|
491
|
+
case "devices":
|
|
492
|
+
replace(state.devices, (d) => d.id === key, byCreated)
|
|
493
|
+
return
|
|
494
|
+
case "push":
|
|
495
|
+
replace(state.push, (p) => pushKey(p.endpoint) === key, (a, b) => a.created - b.created)
|
|
496
|
+
return
|
|
497
|
+
case "settings":
|
|
498
|
+
setSetting(state, key, value)
|
|
499
|
+
return
|
|
500
|
+
case "audit": {
|
|
501
|
+
const index = state.audit.findIndex((entry) => rows.auditKey(entry) === key)
|
|
502
|
+
if (value === undefined) {
|
|
503
|
+
if (index >= 0) state.audit.splice(index, 1)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const entry = value as AuditEntry
|
|
507
|
+
if (index >= 0) state.audit.splice(index, 1)
|
|
508
|
+
// Keyed by time: insert in key order.
|
|
509
|
+
rows.rememberAudit(entry, key)
|
|
510
|
+
let at = state.audit.length
|
|
511
|
+
while (at > 0 && (rows.auditKey(state.audit[at - 1]!) ?? "") > key) at--
|
|
512
|
+
state.audit.splice(at, 0, entry)
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
default:
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
}
|