@huynle/hub 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/central/store.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
|
6
6
|
import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
7
7
|
import path from "node:path"
|
|
8
|
+
import { type AgentData, mergeAgentData, normaliseAgentData, validEpoch } from "./agent-data"
|
|
8
9
|
import type { StoredSpeech } from "./speech/settings"
|
|
10
|
+
import { pushKey, SharedRows, StoreTx, type SharedStoreOptions } from "./store-shared"
|
|
9
11
|
import type { ModelSelection, SessionModelPrefs } from "../protocol"
|
|
10
12
|
|
|
11
13
|
/** Owner of everything in pairing mode, and of records from before 0.3.0. */
|
|
@@ -41,6 +43,10 @@ export interface UserRecord {
|
|
|
41
43
|
reads?: Record<string, number>
|
|
42
44
|
/** Sessions whose notifications the user muted, oldest first. */
|
|
43
45
|
mutedSessions?: string[]
|
|
46
|
+
/** Agent control plane data (names, roles, sessions, app tokens); see agent-data.ts. */
|
|
47
|
+
agent?: AgentData
|
|
48
|
+
/** Bumped to invalidate every session token minted for this user (absent = 0). */
|
|
49
|
+
tokenEpoch?: number
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** Workspace keys are the phone's group keys: `herdr:<workspaceID>` or a folder path. */
|
|
@@ -70,9 +76,13 @@ export interface MachineRecord {
|
|
|
70
76
|
lastSeen: number
|
|
71
77
|
revoked?: boolean
|
|
72
78
|
ownerID: string
|
|
79
|
+
/** Bumped to invalidate every session token minted for this machine (absent = 0). */
|
|
80
|
+
tokenEpoch?: number
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Which notifications a device wants: one toggle per kind (see docs/notifications.md for what triggers each).
|
|
85
|
+
*/
|
|
76
86
|
export interface PushPrefs {
|
|
77
87
|
/** A session sent to the phone from the terminal. */
|
|
78
88
|
handoff: boolean
|
|
@@ -80,29 +90,90 @@ export interface PushPrefs {
|
|
|
80
90
|
question: boolean
|
|
81
91
|
/** A session waits for a permission. */
|
|
82
92
|
permission: boolean
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
/** A root session's run failed. */
|
|
93
|
+
/** All done: a root session and its whole tree settled (nothing runs or waits), once per run. */
|
|
94
|
+
done: boolean
|
|
95
|
+
/** A root session's run failed (provider or tool error; not a user stop). */
|
|
86
96
|
failed: boolean
|
|
97
|
+
/** Every root turn finished (0.7's "finished": noisy with background subagents). */
|
|
98
|
+
turn: boolean
|
|
99
|
+
/** A root run stopped without the user asking (shutdown, inactivity). */
|
|
100
|
+
stopped: boolean
|
|
101
|
+
/** The provider is retrying a root session's request (once per retry streak). */
|
|
102
|
+
retry: boolean
|
|
103
|
+
/** A subagent session finished. */
|
|
104
|
+
subagentDone: boolean
|
|
105
|
+
/** A subagent session failed. */
|
|
106
|
+
subagentFailed: boolean
|
|
107
|
+
/** A shell job or terminal exited 0. */
|
|
108
|
+
jobDone: boolean
|
|
109
|
+
/** A shell job or terminal exited non-zero, was killed or timed out. */
|
|
110
|
+
jobFailed: boolean
|
|
111
|
+
/** A tool call that ran at least 30 s ended (subagent calls excluded). */
|
|
112
|
+
longTool: boolean
|
|
87
113
|
/** Conversation mode replies while no device is watching the session. */
|
|
88
114
|
say: boolean
|
|
115
|
+
/** Activity updates: a collapsing digest per session (subagents, long tools, jobs ending, idle), at most one per interval. */
|
|
116
|
+
activity: boolean
|
|
117
|
+
/** A session (or app) asks you to decide something (`hub ask @me`): a Decision waits for your answer. */
|
|
118
|
+
decision: boolean
|
|
119
|
+
/** Agent messaging needs you: a message to @me, an ask left unanswered after every re-wake, or a message loop. */
|
|
120
|
+
message: boolean
|
|
89
121
|
}
|
|
90
122
|
|
|
91
|
-
export const PUSH_PREF_KEYS = [
|
|
92
|
-
|
|
93
|
-
|
|
123
|
+
export const PUSH_PREF_KEYS = [
|
|
124
|
+
"handoff",
|
|
125
|
+
"question",
|
|
126
|
+
"permission",
|
|
127
|
+
"done",
|
|
128
|
+
"failed",
|
|
129
|
+
"turn",
|
|
130
|
+
"stopped",
|
|
131
|
+
"retry",
|
|
132
|
+
"subagentDone",
|
|
133
|
+
"subagentFailed",
|
|
134
|
+
"jobDone",
|
|
135
|
+
"jobFailed",
|
|
136
|
+
"longTool",
|
|
137
|
+
"say",
|
|
138
|
+
"activity",
|
|
139
|
+
"decision",
|
|
140
|
+
"message",
|
|
141
|
+
] as const satisfies readonly (keyof PushPrefs)[]
|
|
142
|
+
|
|
143
|
+
/** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
|
|
94
144
|
export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
|
|
95
145
|
handoff: true,
|
|
96
146
|
question: true,
|
|
97
147
|
permission: true,
|
|
98
|
-
|
|
99
|
-
failed:
|
|
148
|
+
done: true,
|
|
149
|
+
failed: true,
|
|
150
|
+
turn: false,
|
|
151
|
+
stopped: false,
|
|
152
|
+
retry: false,
|
|
153
|
+
subagentDone: false,
|
|
154
|
+
subagentFailed: false,
|
|
155
|
+
jobDone: false,
|
|
156
|
+
jobFailed: false,
|
|
157
|
+
longTool: false,
|
|
100
158
|
say: true,
|
|
159
|
+
// Opt-in per device (Settings → Notifications → Activity updates).
|
|
160
|
+
activity: false,
|
|
161
|
+
decision: true,
|
|
162
|
+
message: true,
|
|
101
163
|
})
|
|
102
164
|
|
|
103
|
-
/**
|
|
165
|
+
/** Default and allowed range of a device's activity update interval, in minutes. */
|
|
166
|
+
export const ACTIVITY_INTERVAL_MINUTES = 5
|
|
167
|
+
export const ACTIVITY_INTERVAL_MIN = 1
|
|
168
|
+
export const ACTIVITY_INTERVAL_MAX = 60
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Older prefs: 0.5.0's one toggle for questions and permissions (`blocked`), and up to 0.7 `finished` (every root
|
|
172
|
+
* turn, which fired while background subagents still ran); it now means all done (`done`).
|
|
173
|
+
*/
|
|
104
174
|
export interface LegacyPushPrefs {
|
|
105
175
|
blocked?: boolean
|
|
176
|
+
finished?: boolean
|
|
106
177
|
}
|
|
107
178
|
|
|
108
179
|
/** A device's quiet hours: local "HH:MM" times in an IANA time zone; start > end spans midnight. */
|
|
@@ -137,7 +208,7 @@ export function quietHoursInput(value: unknown): QuietHours | undefined {
|
|
|
137
208
|
/** Most sessions a user can mute (oldest are forgotten first). */
|
|
138
209
|
export const MUTED_SESSIONS_LIMIT = 500
|
|
139
210
|
|
|
140
|
-
/** Current prefs from a stored (possibly 0.5.0) record. */
|
|
211
|
+
/** Current prefs from a stored (possibly 0.5.0 or 0.7) record. */
|
|
141
212
|
function normalizePushPrefs(stored: (Partial<PushPrefs> & LegacyPushPrefs) | undefined): PushPrefs {
|
|
142
213
|
const out: PushPrefs = { ...DEFAULT_PUSH_PREFS }
|
|
143
214
|
if (!stored) return out
|
|
@@ -147,10 +218,14 @@ function normalizePushPrefs(stored: (Partial<PushPrefs> & LegacyPushPrefs) | und
|
|
|
147
218
|
// 0.5.0's "finished" covered failed runs too.
|
|
148
219
|
if (typeof stored.finished === "boolean" && typeof stored.failed !== "boolean") out.failed = stored.finished
|
|
149
220
|
}
|
|
221
|
+
if (typeof stored.finished === "boolean" && typeof stored.done !== "boolean") out.done = stored.finished
|
|
150
222
|
for (const key of PUSH_PREF_KEYS) if (typeof stored[key] === "boolean") out[key] = stored[key]
|
|
151
223
|
return out
|
|
152
224
|
}
|
|
153
225
|
|
|
226
|
+
/** True when stored prefs use a legacy key (rewritten on open). */
|
|
227
|
+
const legacyPushPrefs = (stored: object | undefined): boolean => !!stored && ("blocked" in stored || "finished" in stored)
|
|
228
|
+
|
|
154
229
|
export interface DeviceRecord {
|
|
155
230
|
id: string
|
|
156
231
|
name: string
|
|
@@ -159,7 +234,11 @@ export interface DeviceRecord {
|
|
|
159
234
|
lastSeen: number
|
|
160
235
|
pushPrefs?: Partial<PushPrefs> & LegacyPushPrefs
|
|
161
236
|
quietHours?: QuietHours
|
|
237
|
+
/** Minutes between two activity updates of one session (ACTIVITY_INTERVAL_MINUTES when absent). */
|
|
238
|
+
activityMinutes?: number
|
|
162
239
|
ownerID: string
|
|
240
|
+
/** Public key a native app registered at link time (PEM or base64), for later strong approvals. */
|
|
241
|
+
publicKey?: string
|
|
163
242
|
}
|
|
164
243
|
|
|
165
244
|
export interface VapidKeys {
|
|
@@ -189,6 +268,8 @@ export interface StateData {
|
|
|
189
268
|
users: UserRecord[]
|
|
190
269
|
/** Newest last, at most AUDIT_LIMIT. */
|
|
191
270
|
audit: AuditEntry[]
|
|
271
|
+
/** The admin's hub-wide kill switch: every agent action is paused for every user (reading and messaging still work). */
|
|
272
|
+
agentsActingPaused?: true
|
|
192
273
|
}
|
|
193
274
|
|
|
194
275
|
export interface StoreOptions {
|
|
@@ -255,39 +336,113 @@ function findByToken<T extends { tokenHash: string }>(records: readonly T[], tok
|
|
|
255
336
|
return found
|
|
256
337
|
}
|
|
257
338
|
|
|
339
|
+
/** Shared persistence (cluster mode): see ./store-shared. */
|
|
340
|
+
export { HUB_ROWS, STORE_CACHE_TTL_MS, STORE_CHANGED_TOPIC, StoreConflictError, type SharedStoreOptions } from "./store-shared"
|
|
341
|
+
|
|
342
|
+
/** Records whose rows a security re-check reads again (Store.recheck). */
|
|
343
|
+
export interface RecheckRefs {
|
|
344
|
+
readonly users?: readonly (string | undefined)[]
|
|
345
|
+
readonly machines?: readonly (string | undefined)[]
|
|
346
|
+
readonly devices?: readonly (string | undefined)[]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The hub's records. Reads are synchronous (from memory); mutations are async. Two persistences:
|
|
351
|
+
* - file (Store.open): state.json, written synchronously inside every mutation (a mutation's change is applied
|
|
352
|
+
* and saved before its promise is returned), for the single-process hub;
|
|
353
|
+
* - shared (Store.shared): rows in a StateStore with a per-pod cache, for cluster mode (see ./store-shared).
|
|
354
|
+
* Token lookups and recheck() go to the database, so a revocation on another pod applies at once.
|
|
355
|
+
*/
|
|
258
356
|
export class Store {
|
|
259
357
|
private constructor(
|
|
260
|
-
private readonly file: string,
|
|
261
|
-
private
|
|
358
|
+
private readonly file: string | undefined,
|
|
359
|
+
private state: StateData,
|
|
262
360
|
private readonly now: () => number,
|
|
361
|
+
private readonly shared?: SharedRows,
|
|
263
362
|
) {}
|
|
264
363
|
|
|
265
364
|
static open(dir: string, now: () => number = Date.now, options: StoreOptions = {}): Store {
|
|
266
365
|
prepareStateDir(dir, options)
|
|
267
366
|
const file = path.join(dir, "state.json")
|
|
268
|
-
let state
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
state = {
|
|
273
|
-
machines: Array.isArray(raw.machines) ? raw.machines : [],
|
|
274
|
-
devices: Array.isArray(raw.devices) ? raw.devices : [],
|
|
275
|
-
...(raw.vapid ? { vapid: raw.vapid } : {}),
|
|
276
|
-
push: Array.isArray(raw.push) ? raw.push : [],
|
|
277
|
-
...(isRecord(raw.speech) ? { speech: raw.speech } : {}),
|
|
278
|
-
users: Array.isArray(raw.users) ? raw.users : [],
|
|
279
|
-
audit: Array.isArray(raw.audit) ? raw.audit : [],
|
|
280
|
-
}
|
|
281
|
-
} catch (cause) {
|
|
282
|
-
throw new Error(`Cannot read ${file}: ${cause instanceof Error ? cause.message : cause}. Fix or move it away.`)
|
|
283
|
-
}
|
|
367
|
+
let state = Store.read(dir, now, options)
|
|
368
|
+
if (!state) {
|
|
369
|
+
state = { machines: [], devices: [], push: [], users: [], audit: [] }
|
|
370
|
+
migrate(state, now(), options)
|
|
284
371
|
}
|
|
285
|
-
migrate(state, now(), options)
|
|
286
372
|
const store = new Store(file, state, now)
|
|
287
373
|
store.save()
|
|
288
374
|
return store
|
|
289
375
|
}
|
|
290
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The records in <dir>/state.json, upgraded from older formats, without writing anything (`hub-server import`);
|
|
379
|
+
* undefined when there is no state.json.
|
|
380
|
+
*/
|
|
381
|
+
static read(dir: string, now: () => number = Date.now, options: Pick<StoreOptions, "mode"> = {}): StateData | undefined {
|
|
382
|
+
const file = path.join(dir, "state.json")
|
|
383
|
+
if (!existsSync(file)) return undefined
|
|
384
|
+
let state: StateData
|
|
385
|
+
try {
|
|
386
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<StateData>
|
|
387
|
+
state = {
|
|
388
|
+
machines: Array.isArray(raw.machines) ? raw.machines : [],
|
|
389
|
+
devices: Array.isArray(raw.devices) ? raw.devices : [],
|
|
390
|
+
...(raw.vapid ? { vapid: raw.vapid } : {}),
|
|
391
|
+
push: Array.isArray(raw.push) ? raw.push : [],
|
|
392
|
+
...(isRecord(raw.speech) ? { speech: raw.speech } : {}),
|
|
393
|
+
users: Array.isArray(raw.users) ? raw.users : [],
|
|
394
|
+
audit: Array.isArray(raw.audit) ? raw.audit : [],
|
|
395
|
+
...(raw.agentsActingPaused === true ? { agentsActingPaused: true as const } : {}),
|
|
396
|
+
}
|
|
397
|
+
} catch (cause) {
|
|
398
|
+
throw new Error(`Cannot read ${file}: ${cause instanceof Error ? cause.message : cause}. Fix or move it away.`)
|
|
399
|
+
}
|
|
400
|
+
migrate(state, now(), options)
|
|
401
|
+
return state
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A Store on shared backends (cluster mode). Nothing is read until ready() (mutations and async lookups call
|
|
406
|
+
* it; the hub awaits it before serving). No data migration runs here: the schema and imports are
|
|
407
|
+
* `hub-server migrate` / `import`'s job.
|
|
408
|
+
*/
|
|
409
|
+
static shared(options: SharedStoreOptions): Store {
|
|
410
|
+
let store: Store | undefined = undefined
|
|
411
|
+
const rows = new SharedRows(options, {
|
|
412
|
+
get: () => store!.state,
|
|
413
|
+
set: (state) => {
|
|
414
|
+
store!.state = state
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
store = new Store(undefined, SharedRows.emptyState(), options.now ?? Date.now, rows)
|
|
418
|
+
return store
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Resolves once the records are loaded (at once for the file Store). */
|
|
422
|
+
ready(): Promise<void> {
|
|
423
|
+
return this.shared ? this.shared.ready() : Promise.resolve()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Stops listening for other pods' changes (shared); nothing to do for the file Store. */
|
|
427
|
+
async close(): Promise<void> {
|
|
428
|
+
await this.shared?.close()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Reads these records again from the database (shared Store; a no-op for the file Store), so the synchronous
|
|
433
|
+
* reads after it (revoked, epochs, disabled, owner) see other pods' latest writes.
|
|
434
|
+
*/
|
|
435
|
+
async recheck(refs: RecheckRefs): Promise<void> {
|
|
436
|
+
const shared = this.shared
|
|
437
|
+
if (!shared) return
|
|
438
|
+
await shared.ready()
|
|
439
|
+
const all: Promise<void>[] = []
|
|
440
|
+
for (const collection of ["users", "machines", "devices"] as const) {
|
|
441
|
+
for (const key of new Set(refs[collection] ?? [])) if (key) all.push(shared.refresh({ collection, key }))
|
|
442
|
+
}
|
|
443
|
+
await Promise.all(all)
|
|
444
|
+
}
|
|
445
|
+
|
|
291
446
|
data(): Readonly<StateData> {
|
|
292
447
|
return this.state
|
|
293
448
|
}
|
|
@@ -300,55 +455,92 @@ export class Store {
|
|
|
300
455
|
return this.state.devices
|
|
301
456
|
}
|
|
302
457
|
|
|
303
|
-
addMachine(name: string, ownerID: string = LOCAL_USER): { machine: MachineRecord; token: string } {
|
|
458
|
+
addMachine(name: string, ownerID: string = LOCAL_USER): Promise<{ machine: MachineRecord; token: string }> {
|
|
304
459
|
const token = newToken()
|
|
305
460
|
const t = this.now()
|
|
306
461
|
const machine: MachineRecord = { id: newID("mch"), name, tokenHash: hashToken(token), created: t, lastSeen: t, ownerID }
|
|
307
|
-
this.
|
|
308
|
-
|
|
309
|
-
|
|
462
|
+
return this.mutate((tx) => {
|
|
463
|
+
if (!this.state.machines.some((m) => m.id === machine.id)) this.state.machines.push(machine)
|
|
464
|
+
tx.touch("machines", machine.id)
|
|
465
|
+
tx.touch("machine-tokens", machine.tokenHash)
|
|
466
|
+
return { machine, token }
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Non-revoked machine for a bearer token (the shared Store re-reads it from the database). */
|
|
471
|
+
async findMachine(token: string): Promise<MachineRecord | undefined> {
|
|
472
|
+
if (this.shared && token) await this.recheckToken("machine", token)
|
|
473
|
+
return this.cachedMachine(token)
|
|
310
474
|
}
|
|
311
475
|
|
|
312
|
-
/**
|
|
313
|
-
|
|
476
|
+
/** findMachine from memory only (the file Store; or a shared Store's cache, possibly stale). */
|
|
477
|
+
cachedMachine(token: string): MachineRecord | undefined {
|
|
314
478
|
return findByToken(
|
|
315
479
|
this.state.machines.filter((machine) => !machine.revoked),
|
|
316
480
|
token,
|
|
317
481
|
)
|
|
318
482
|
}
|
|
319
483
|
|
|
320
|
-
revokeMachine(id: string): boolean {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
484
|
+
revokeMachine(id: string): Promise<boolean> {
|
|
485
|
+
return this.mutate((tx) => {
|
|
486
|
+
const machine = this.state.machines.find((m) => m.id === id)
|
|
487
|
+
if (!machine || machine.revoked) return false
|
|
488
|
+
machine.revoked = true
|
|
489
|
+
tx.touch("machines", id)
|
|
490
|
+
return true
|
|
491
|
+
})
|
|
326
492
|
}
|
|
327
493
|
|
|
328
|
-
|
|
329
|
-
|
|
494
|
+
/** Records that the machine was seen (persisted at most once a minute; never rejects). */
|
|
495
|
+
touchMachine(id: string): Promise<void> {
|
|
496
|
+
return this.touch("machines", id, () => this.state.machines.find((m) => m.id === id))
|
|
330
497
|
}
|
|
331
498
|
|
|
332
|
-
addDevice(name: string, ownerID: string = LOCAL_USER): { device: DeviceRecord; token: string } {
|
|
499
|
+
addDevice(name: string, ownerID: string = LOCAL_USER, extra: { publicKey?: string } = {}): Promise<{ device: DeviceRecord; token: string }> {
|
|
333
500
|
const token = newToken()
|
|
334
501
|
const t = this.now()
|
|
335
502
|
const device: DeviceRecord = { id: newID("dev"), name, tokenHash: hashToken(token), created: t, lastSeen: t, ownerID }
|
|
336
|
-
|
|
337
|
-
this.
|
|
338
|
-
|
|
503
|
+
if (extra.publicKey) device.publicKey = extra.publicKey
|
|
504
|
+
return this.mutate((tx) => {
|
|
505
|
+
if (!this.state.devices.some((d) => d.id === device.id)) this.state.devices.push(device)
|
|
506
|
+
tx.touch("devices", device.id)
|
|
507
|
+
tx.touch("device-tokens", device.tokenHash)
|
|
508
|
+
return { device, token }
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The device for a bearer token (the shared Store re-reads it from the database). */
|
|
513
|
+
async findDevice(token: string): Promise<DeviceRecord | undefined> {
|
|
514
|
+
if (this.shared && token) await this.recheckToken("device", token)
|
|
515
|
+
return this.cachedDevice(token)
|
|
339
516
|
}
|
|
340
517
|
|
|
341
|
-
findDevice(
|
|
518
|
+
/** findDevice from memory only (the file Store; or a shared Store's cache, possibly stale). */
|
|
519
|
+
cachedDevice(token: string): DeviceRecord | undefined {
|
|
342
520
|
return findByToken(this.state.devices, token)
|
|
343
521
|
}
|
|
344
522
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
this.
|
|
351
|
-
|
|
523
|
+
/** Re-reads the record of a token: found in the cache by hash, else through its token-hash index row. */
|
|
524
|
+
private async recheckToken(kind: "machine" | "device", token: string): Promise<void> {
|
|
525
|
+
const shared = this.shared!
|
|
526
|
+
await shared.ready()
|
|
527
|
+
const hash = hashToken(token)
|
|
528
|
+
const records: readonly { id: string; tokenHash: string }[] = kind === "machine" ? this.state.machines : this.state.devices
|
|
529
|
+
const id = findByToken(records, token)?.id ?? (await shared.indexed(kind === "machine" ? "machine-tokens" : "device-tokens", hash))
|
|
530
|
+
if (id) await shared.refresh({ collection: kind === "machine" ? "machines" : "devices", key: id })
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
removeDevice(id: string): Promise<boolean> {
|
|
534
|
+
return this.mutate((tx) => {
|
|
535
|
+
const device = this.state.devices.find((d) => d.id === id)
|
|
536
|
+
if (!device) return false
|
|
537
|
+
this.state.devices = this.state.devices.filter((d) => d.id !== id)
|
|
538
|
+
tx.touch("devices", id)
|
|
539
|
+
tx.touch("device-tokens", device.tokenHash)
|
|
540
|
+
for (const p of this.state.push) if (p.deviceID === id) tx.touch("push", pushKey(p.endpoint))
|
|
541
|
+
this.state.push = this.state.push.filter((p) => p.deviceID !== id)
|
|
542
|
+
return true
|
|
543
|
+
})
|
|
352
544
|
}
|
|
353
545
|
|
|
354
546
|
// ---------- web push ----------
|
|
@@ -357,9 +549,17 @@ export class Store {
|
|
|
357
549
|
return this.state.vapid
|
|
358
550
|
}
|
|
359
551
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Stores the hub's VAPID keys unless some are stored already (by another pod too: the first write wins), and
|
|
554
|
+
* returns the stored pair.
|
|
555
|
+
*/
|
|
556
|
+
setVapid(keys: VapidKeys): Promise<VapidKeys> {
|
|
557
|
+
return this.mutate((tx) => {
|
|
558
|
+
if (this.state.vapid) return this.state.vapid
|
|
559
|
+
this.state.vapid = { publicKey: keys.publicKey, privateKey: keys.privateKey }
|
|
560
|
+
tx.touch("settings", "vapid")
|
|
561
|
+
return this.state.vapid
|
|
562
|
+
})
|
|
363
563
|
}
|
|
364
564
|
|
|
365
565
|
pushSubscriptions(): readonly PushSubscriptionRecord[] {
|
|
@@ -367,27 +567,29 @@ export class Store {
|
|
|
367
567
|
}
|
|
368
568
|
|
|
369
569
|
/** Adds or replaces (by endpoint) a subscription; an endpoint belongs to one device. */
|
|
370
|
-
addPushSubscription(deviceID: string, target: PushTarget): PushSubscriptionRecord {
|
|
570
|
+
addPushSubscription(deviceID: string, target: PushTarget): Promise<PushSubscriptionRecord> {
|
|
371
571
|
const record: PushSubscriptionRecord = {
|
|
372
572
|
deviceID,
|
|
373
573
|
endpoint: target.endpoint,
|
|
374
574
|
keys: { p256dh: target.keys.p256dh, auth: target.keys.auth },
|
|
375
575
|
created: this.now(),
|
|
376
576
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
577
|
+
return this.mutate((tx) => {
|
|
578
|
+
const index = this.state.push.findIndex((p) => p.endpoint === target.endpoint)
|
|
579
|
+
if (index >= 0) this.state.push[index] = record
|
|
580
|
+
else this.state.push.push(record)
|
|
581
|
+
tx.touch("push", pushKey(record.endpoint))
|
|
582
|
+
return record
|
|
583
|
+
})
|
|
382
584
|
}
|
|
383
585
|
|
|
384
586
|
/** Removes a device's own subscription. */
|
|
385
|
-
removePushSubscription(deviceID: string, endpoint: string): boolean {
|
|
587
|
+
removePushSubscription(deviceID: string, endpoint: string): Promise<boolean> {
|
|
386
588
|
return this.dropPush((p) => p.deviceID === deviceID && p.endpoint === endpoint)
|
|
387
589
|
}
|
|
388
590
|
|
|
389
591
|
/** Removes a subscription the push service reported as gone. */
|
|
390
|
-
dropPushSubscription(endpoint: string): boolean {
|
|
592
|
+
dropPushSubscription(endpoint: string): Promise<boolean> {
|
|
391
593
|
return this.dropPush((p) => p.endpoint === endpoint)
|
|
392
594
|
}
|
|
393
595
|
|
|
@@ -396,16 +598,35 @@ export class Store {
|
|
|
396
598
|
return normalizePushPrefs(device?.pushPrefs)
|
|
397
599
|
}
|
|
398
600
|
|
|
399
|
-
/** Applies the boolean toggles given; a legacy `blocked` sets question and permission. */
|
|
400
|
-
setPushPrefs(deviceID: string, prefs: Partial<PushPrefs> & LegacyPushPrefs): PushPrefs {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
601
|
+
/** Applies the boolean toggles given; a legacy `blocked` sets question and permission, a legacy `finished` all done. */
|
|
602
|
+
setPushPrefs(deviceID: string, prefs: Partial<PushPrefs> & LegacyPushPrefs): Promise<PushPrefs> {
|
|
603
|
+
return this.mutate((tx) => {
|
|
604
|
+
const device = this.state.devices.find((d) => d.id === deviceID)
|
|
605
|
+
if (!device) return { ...DEFAULT_PUSH_PREFS }
|
|
606
|
+
const next = this.pushPrefs(deviceID)
|
|
607
|
+
if (typeof prefs.blocked === "boolean") next.question = next.permission = prefs.blocked
|
|
608
|
+
if (typeof prefs.finished === "boolean") next.done = prefs.finished
|
|
609
|
+
for (const key of PUSH_PREF_KEYS) if (typeof prefs[key] === "boolean") next[key] = prefs[key]
|
|
610
|
+
device.pushPrefs = next
|
|
611
|
+
tx.touch("devices", deviceID)
|
|
612
|
+
return { ...next }
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Minutes between two activity updates of one session on this device. */
|
|
617
|
+
activityMinutes(deviceID: string): number {
|
|
618
|
+
return this.state.devices.find((d) => d.id === deviceID)?.activityMinutes ?? ACTIVITY_INTERVAL_MINUTES
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Sets a device's activity update interval (clamped to 1–60 whole minutes). */
|
|
622
|
+
setActivityMinutes(deviceID: string, minutes: number): Promise<number> {
|
|
623
|
+
return this.mutate((tx) => {
|
|
624
|
+
const device = this.state.devices.find((d) => d.id === deviceID)
|
|
625
|
+
if (!device) return ACTIVITY_INTERVAL_MINUTES
|
|
626
|
+
device.activityMinutes = Math.min(ACTIVITY_INTERVAL_MAX, Math.max(ACTIVITY_INTERVAL_MIN, Math.round(minutes)))
|
|
627
|
+
tx.touch("devices", deviceID)
|
|
628
|
+
return device.activityMinutes
|
|
629
|
+
})
|
|
409
630
|
}
|
|
410
631
|
|
|
411
632
|
quietHours(deviceID: string): QuietHours | undefined {
|
|
@@ -414,36 +635,42 @@ export class Store {
|
|
|
414
635
|
}
|
|
415
636
|
|
|
416
637
|
/** Sets (validated by the caller) or clears (null) a device's quiet hours. */
|
|
417
|
-
setQuietHours(deviceID: string, quiet: QuietHours | null): QuietHours | undefined {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
638
|
+
setQuietHours(deviceID: string, quiet: QuietHours | null): Promise<QuietHours | undefined> {
|
|
639
|
+
return this.mutate((tx) => {
|
|
640
|
+
const device = this.state.devices.find((d) => d.id === deviceID)
|
|
641
|
+
if (!device) return undefined
|
|
642
|
+
if (quiet) device.quietHours = { start: quiet.start, end: quiet.end, timeZone: quiet.timeZone }
|
|
643
|
+
else delete device.quietHours
|
|
644
|
+
tx.touch("devices", deviceID)
|
|
645
|
+
return this.quietHours(deviceID)
|
|
646
|
+
})
|
|
424
647
|
}
|
|
425
648
|
|
|
426
649
|
mutedSessions(userID: string): string[] {
|
|
427
650
|
return [...(this.user(userID)?.mutedSessions ?? [])]
|
|
428
651
|
}
|
|
429
652
|
|
|
430
|
-
setSessionMuted(userID: string, sessionID: string, muted: boolean): string[] {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
653
|
+
setSessionMuted(userID: string, sessionID: string, muted: boolean): Promise<string[]> {
|
|
654
|
+
return this.mutate((tx) => {
|
|
655
|
+
const user = this.user(userID)
|
|
656
|
+
if (!user) return []
|
|
657
|
+
const list = (user.mutedSessions ?? []).filter((id) => id !== sessionID)
|
|
658
|
+
if (muted) list.push(sessionID)
|
|
659
|
+
while (list.length > MUTED_SESSIONS_LIMIT) list.shift()
|
|
660
|
+
user.mutedSessions = list
|
|
661
|
+
tx.touch("users", userID)
|
|
662
|
+
return [...list]
|
|
663
|
+
})
|
|
439
664
|
}
|
|
440
665
|
|
|
441
|
-
private dropPush(match: (record: PushSubscriptionRecord) => boolean): boolean {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
666
|
+
private dropPush(match: (record: PushSubscriptionRecord) => boolean): Promise<boolean> {
|
|
667
|
+
return this.mutate((tx) => {
|
|
668
|
+
const gone = this.state.push.filter(match)
|
|
669
|
+
if (!gone.length) return false
|
|
670
|
+
this.state.push = this.state.push.filter((p) => !match(p))
|
|
671
|
+
for (const p of gone) tx.touch("push", pushKey(p.endpoint))
|
|
672
|
+
return true
|
|
673
|
+
})
|
|
447
674
|
}
|
|
448
675
|
|
|
449
676
|
// ---------- speech settings ----------
|
|
@@ -453,9 +680,11 @@ export class Store {
|
|
|
453
680
|
return structuredClone(this.state.speech ?? {})
|
|
454
681
|
}
|
|
455
682
|
|
|
456
|
-
setSpeechSettings(settings: StoredSpeech): void {
|
|
457
|
-
this.
|
|
458
|
-
|
|
683
|
+
setSpeechSettings(settings: StoredSpeech): Promise<void> {
|
|
684
|
+
return this.mutate((tx) => {
|
|
685
|
+
this.state.speech = structuredClone(settings)
|
|
686
|
+
tx.touch("settings", "speech")
|
|
687
|
+
})
|
|
459
688
|
}
|
|
460
689
|
|
|
461
690
|
// ---------- users ----------
|
|
@@ -477,56 +706,167 @@ export class Store {
|
|
|
477
706
|
* Creates the user for a (verified, lowercased) email, or updates its name and lastSeen. Role
|
|
478
707
|
* "admin" promotes an existing user (admins come from the config); "user" never demotes.
|
|
479
708
|
*/
|
|
480
|
-
upsertUser(input: { email: string; name?: string; role: Role }): UserRecord {
|
|
709
|
+
upsertUser(input: { email: string; name?: string; role: Role }): Promise<UserRecord> {
|
|
481
710
|
const email = input.email.trim().toLowerCase()
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
user =
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
711
|
+
const id = newUserID()
|
|
712
|
+
return this.mutate((tx) => {
|
|
713
|
+
const t = this.now()
|
|
714
|
+
let user = this.userByEmail(email)
|
|
715
|
+
if (!user) {
|
|
716
|
+
user = { id, email, ...(input.name ? { name: input.name } : {}), role: input.role, created: t, lastSeen: t }
|
|
717
|
+
this.state.users.push(user)
|
|
718
|
+
tx.touch("user-emails", email)
|
|
719
|
+
} else {
|
|
720
|
+
if (input.name) user.name = input.name
|
|
721
|
+
if (input.role === "admin") user.role = "admin"
|
|
722
|
+
user.lastSeen = t
|
|
723
|
+
}
|
|
724
|
+
tx.touch("users", user.id)
|
|
725
|
+
return user
|
|
726
|
+
})
|
|
494
727
|
}
|
|
495
728
|
|
|
496
|
-
setUser(id: string, change: { role?: Role; disabled?: boolean }): UserRecord | undefined {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
729
|
+
setUser(id: string, change: { role?: Role; disabled?: boolean }): Promise<UserRecord | undefined> {
|
|
730
|
+
return this.mutate((tx) => {
|
|
731
|
+
const user = this.user(id)
|
|
732
|
+
if (!user) return undefined
|
|
733
|
+
if (change.role) user.role = change.role
|
|
734
|
+
if (change.disabled === true) user.disabled = true
|
|
735
|
+
else if (change.disabled === false) delete user.disabled
|
|
736
|
+
tx.touch("users", id)
|
|
737
|
+
return user
|
|
738
|
+
})
|
|
504
739
|
}
|
|
505
740
|
|
|
506
|
-
|
|
507
|
-
|
|
741
|
+
/** Records that the user was seen (persisted at most once a minute; never rejects). */
|
|
742
|
+
touchUser(id: string): Promise<void> {
|
|
743
|
+
return this.touch("users", id, () => this.user(id))
|
|
508
744
|
}
|
|
509
745
|
|
|
510
746
|
/**
|
|
511
747
|
* First admin sign-in in oidc mode: every record owned by "local" moves to the admin and "local" is
|
|
512
748
|
* disabled. False (nothing changed) once done.
|
|
513
749
|
*/
|
|
514
|
-
migrateLocal(adminID: string): boolean {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
750
|
+
migrateLocal(adminID: string): Promise<boolean> {
|
|
751
|
+
return this.mutate((tx) => {
|
|
752
|
+
const local = this.user(LOCAL_USER)
|
|
753
|
+
if (!local || local.disabled || adminID === LOCAL_USER) return false
|
|
754
|
+
for (const machine of this.state.machines) {
|
|
755
|
+
if (machine.ownerID !== LOCAL_USER) continue
|
|
756
|
+
machine.ownerID = adminID
|
|
757
|
+
machine.tokenEpoch = (machine.tokenEpoch ?? 0) + 1
|
|
758
|
+
tx.touch("machines", machine.id)
|
|
759
|
+
}
|
|
760
|
+
for (const device of this.state.devices) {
|
|
761
|
+
if (device.ownerID !== LOCAL_USER) continue
|
|
762
|
+
device.ownerID = adminID
|
|
763
|
+
tx.touch("devices", device.id)
|
|
764
|
+
}
|
|
765
|
+
const admin = this.user(adminID)
|
|
766
|
+
if (local.agent && admin) {
|
|
767
|
+
admin.agent = mergeAgentData(admin.agent ?? {}, local.agent)
|
|
768
|
+
tx.touch("users", adminID)
|
|
769
|
+
}
|
|
770
|
+
delete local.agent
|
|
771
|
+
local.tokenEpoch = (local.tokenEpoch ?? 0) + 1
|
|
772
|
+
local.disabled = true
|
|
773
|
+
tx.touch("users", LOCAL_USER)
|
|
774
|
+
this.appendAudit(tx, { actor: "system", action: "migrate.local", target: adminID })
|
|
775
|
+
return true
|
|
776
|
+
})
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ---------- agent data and token epochs ----------
|
|
780
|
+
|
|
781
|
+
/** A copy of the user's agent data ({} when none). */
|
|
782
|
+
agentData(userID: string): AgentData {
|
|
783
|
+
return structuredClone(this.user(userID)?.agent ?? {})
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Changes a user's agent data: `change` edits a copy in place or returns a replacement; the result is
|
|
788
|
+
* normalised and saved only when it differs. Undefined for an unknown user. On a shared Store, `change` runs
|
|
789
|
+
* again on fresh data when another pod changed the user meanwhile (so it may run more than once).
|
|
790
|
+
*/
|
|
791
|
+
updateAgentData(userID: string, change: (data: AgentData) => AgentData | void): Promise<AgentData | undefined> {
|
|
792
|
+
return this.mutate((tx) => {
|
|
793
|
+
const user = this.user(userID)
|
|
794
|
+
if (!user) return undefined
|
|
795
|
+
const draft = this.agentData(userID)
|
|
796
|
+
const next = normaliseAgentData(change(draft) ?? draft) ?? {}
|
|
797
|
+
if (JSON.stringify(next) !== JSON.stringify(user.agent ?? {})) {
|
|
798
|
+
if (Object.keys(next).length) user.agent = next
|
|
799
|
+
else delete user.agent
|
|
800
|
+
tx.touch("users", userID)
|
|
801
|
+
}
|
|
802
|
+
return this.agentData(userID)
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
userEpoch(userID: string): number {
|
|
807
|
+
return this.user(userID)?.tokenEpoch ?? 0
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
machineEpoch(machineID: string): number {
|
|
811
|
+
return this.state.machines.find((m) => m.id === machineID)?.tokenEpoch ?? 0
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Invalidates the user's session tokens; returns the new epoch (undefined for an unknown user). */
|
|
815
|
+
bumpUserEpoch(userID: string): Promise<number | undefined> {
|
|
816
|
+
return this.mutate((tx) => this.bumpEpoch(tx, "users", userID, this.user(userID)))
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Invalidates the machine's session tokens; returns the new epoch (undefined for an unknown machine). */
|
|
820
|
+
bumpMachineEpoch(machineID: string): Promise<number | undefined> {
|
|
821
|
+
return this.mutate((tx) => this.bumpEpoch(tx, "machines", machineID, this.state.machines.find((m) => m.id === machineID)))
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private bumpEpoch(tx: StoreTx, collection: "users" | "machines", id: string, record: { tokenEpoch?: number } | undefined): number | undefined {
|
|
825
|
+
if (!record) return undefined
|
|
826
|
+
record.tokenEpoch = (record.tokenEpoch ?? 0) + 1
|
|
827
|
+
tx.touch(collection, id)
|
|
828
|
+
return record.tokenEpoch
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ---------- hub-wide agent acting switch ----------
|
|
832
|
+
|
|
833
|
+
/** True while the admin has paused agent actions hub-wide. */
|
|
834
|
+
actingPaused(): boolean {
|
|
835
|
+
return this.state.agentsActingPaused === true
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Pauses or resumes agent actions hub-wide; true when that changed something (saved only then). */
|
|
839
|
+
setActingPaused(paused: boolean): Promise<boolean> {
|
|
840
|
+
return this.mutate((tx) => {
|
|
841
|
+
if (paused === this.actingPaused()) return false
|
|
842
|
+
if (paused) this.state.agentsActingPaused = true
|
|
843
|
+
else delete this.state.agentsActingPaused
|
|
844
|
+
tx.touch("settings", "agentsActingPaused")
|
|
845
|
+
return true
|
|
846
|
+
})
|
|
521
847
|
}
|
|
522
848
|
|
|
523
849
|
// ---------- audit ----------
|
|
524
850
|
|
|
525
851
|
/** Appends an audit entry. Never pass tokens, codes, keys or session content. */
|
|
526
|
-
audit(entry: Omit<AuditEntry, "at">): void {
|
|
527
|
-
|
|
528
|
-
if (this.
|
|
529
|
-
this.
|
|
852
|
+
audit(entry: Omit<AuditEntry, "at">): Promise<void> {
|
|
853
|
+
// Shared: first the entries other pods wrote (the cache may not have them yet), so this one sorts after them.
|
|
854
|
+
if (this.shared) return this.shared.syncAudit().then(() => this.mutate((tx) => this.appendAudit(tx, entry)))
|
|
855
|
+
return this.mutate((tx) => this.appendAudit(tx, entry))
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private appendAudit(tx: StoreTx, entry: Omit<AuditEntry, "at">): void {
|
|
859
|
+
const record: AuditEntry = { at: this.now(), ...entry }
|
|
860
|
+
this.state.audit.push(record)
|
|
861
|
+
if (this.shared) tx.touch("audit", this.shared.keyAudit(record))
|
|
862
|
+
else tx.touch("audit", "")
|
|
863
|
+
if (this.state.audit.length > AUDIT_LIMIT) {
|
|
864
|
+
const dropped = this.state.audit.splice(0, this.state.audit.length - AUDIT_LIMIT)
|
|
865
|
+
for (const old of dropped) {
|
|
866
|
+
const key = this.shared?.auditKey(old)
|
|
867
|
+
if (key) tx.touch("audit", key)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
530
870
|
}
|
|
531
871
|
|
|
532
872
|
/** Newest first, at most AUDIT_VIEW_LIMIT. */
|
|
@@ -540,11 +880,13 @@ export class Store {
|
|
|
540
880
|
return structuredClone(this.user(id)?.speech ?? {})
|
|
541
881
|
}
|
|
542
882
|
|
|
543
|
-
setUserSpeech(id: string, settings: StoredSpeech): void {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
883
|
+
setUserSpeech(id: string, settings: StoredSpeech): Promise<void> {
|
|
884
|
+
return this.mutate((tx) => {
|
|
885
|
+
const user = this.user(id)
|
|
886
|
+
if (!user) return
|
|
887
|
+
user.speech = structuredClone(settings)
|
|
888
|
+
tx.touch("users", id)
|
|
889
|
+
})
|
|
548
890
|
}
|
|
549
891
|
|
|
550
892
|
// ---------- per-user view preferences ----------
|
|
@@ -557,14 +899,16 @@ export class Store {
|
|
|
557
899
|
}
|
|
558
900
|
|
|
559
901
|
/** Replaces the lists given (callers validate); returns the result. */
|
|
560
|
-
setViewPrefs(id: string, change: Partial<ViewPrefs>): ViewPrefs {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
902
|
+
setViewPrefs(id: string, change: Partial<ViewPrefs>): Promise<ViewPrefs> {
|
|
903
|
+
return this.mutate((tx) => {
|
|
904
|
+
const user = this.user(id)
|
|
905
|
+
if (!user) return this.viewPrefs(id)
|
|
906
|
+
const next = this.viewPrefs(id)
|
|
907
|
+
for (const key of VIEW_PREF_KEYS) if (change[key]) next[key] = [...change[key]]
|
|
908
|
+
user.viewPrefs = next
|
|
909
|
+
tx.touch("users", id)
|
|
910
|
+
return this.viewPrefs(id)
|
|
911
|
+
})
|
|
568
912
|
}
|
|
569
913
|
|
|
570
914
|
// ---------- per-user, per-session model preferences ----------
|
|
@@ -574,22 +918,24 @@ export class Store {
|
|
|
574
918
|
return prefs ? structuredClone(prefs) : {}
|
|
575
919
|
}
|
|
576
920
|
|
|
577
|
-
setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): SessionModelPrefs {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
921
|
+
setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): Promise<SessionModelPrefs> {
|
|
922
|
+
return this.mutate((tx) => {
|
|
923
|
+
const user = this.user(id)
|
|
924
|
+
if (!user) return {}
|
|
925
|
+
const all = user.modelPrefs ?? (user.modelPrefs = {})
|
|
926
|
+
const next: { voice?: ModelSelection; worker?: ModelSelection } = this.modelPrefs(id, sessionID)
|
|
927
|
+
for (const key of ["voice", "worker"] as const) {
|
|
928
|
+
if (!(key in change)) continue
|
|
929
|
+
const value = change[key]
|
|
930
|
+
if (value === null) delete next[key]
|
|
931
|
+
else if (value !== undefined) next[key] = structuredClone(value)
|
|
932
|
+
}
|
|
933
|
+
delete all[sessionID]
|
|
934
|
+
if (next.voice || next.worker) all[sessionID] = next
|
|
935
|
+
while (Object.keys(all).length > MODEL_PREFS_LIMIT) delete all[Object.keys(all)[0]!]
|
|
936
|
+
tx.touch("users", id)
|
|
937
|
+
return this.modelPrefs(id, sessionID)
|
|
938
|
+
})
|
|
593
939
|
}
|
|
594
940
|
|
|
595
941
|
// ---------- per-user read marks (unread dots) ----------
|
|
@@ -599,40 +945,66 @@ export class Store {
|
|
|
599
945
|
}
|
|
600
946
|
|
|
601
947
|
/** Marks a session read at `at`; a mark never moves back. Returns all marks. */
|
|
602
|
-
markRead(id: string, sessionID: string, at: number): Record<string, number
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
948
|
+
markRead(id: string, sessionID: string, at: number): Promise<Record<string, number>> {
|
|
949
|
+
return this.mutate((tx) => {
|
|
950
|
+
const user = this.user(id)
|
|
951
|
+
if (!user) return {}
|
|
952
|
+
const all = user.reads ?? (user.reads = {})
|
|
953
|
+
const next = Math.max(all[sessionID] ?? 0, at)
|
|
954
|
+
delete all[sessionID]
|
|
955
|
+
all[sessionID] = next
|
|
956
|
+
while (Object.keys(all).length > READS_LIMIT) delete all[Object.keys(all)[0]!]
|
|
957
|
+
tx.touch("users", id)
|
|
958
|
+
return this.reads(id)
|
|
959
|
+
})
|
|
612
960
|
}
|
|
613
961
|
|
|
614
|
-
|
|
615
|
-
|
|
962
|
+
/** Records that the device was seen (persisted at most once a minute; never rejects). */
|
|
963
|
+
touchDevice(id: string): Promise<void> {
|
|
964
|
+
return this.touch("devices", id, () => this.state.devices.find((d) => d.id === id))
|
|
616
965
|
}
|
|
617
966
|
|
|
618
|
-
private touch(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
967
|
+
private touch(collection: "users" | "machines" | "devices", id: string, find: () => { lastSeen: number } | undefined): Promise<void> {
|
|
968
|
+
return this.mutate((tx) => {
|
|
969
|
+
const record = find()
|
|
970
|
+
if (!record) return
|
|
971
|
+
const t = this.now()
|
|
972
|
+
if (t - record.lastSeen < TOUCH_PERSIST_MS) return
|
|
973
|
+
record.lastSeen = t
|
|
974
|
+
tx.touch(collection, id)
|
|
975
|
+
}).catch((cause: unknown) => console.warn(`[hub] lastSeen not saved: ${(cause as Error)?.message ?? cause}`))
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Runs a change. File Store: the change and its save happen synchronously, before the promise is returned (a
|
|
980
|
+
* failed save rejects it). Shared Store: see SharedRows.mutate.
|
|
981
|
+
*/
|
|
982
|
+
private mutate<T>(fn: (tx: StoreTx) => T): Promise<T> {
|
|
983
|
+
if (this.shared) return this.shared.mutate(fn)
|
|
984
|
+
try {
|
|
985
|
+
const tx = new StoreTx()
|
|
986
|
+
const result = fn(tx)
|
|
987
|
+
if (tx.dirty) this.save()
|
|
988
|
+
return Promise.resolve(result)
|
|
989
|
+
} catch (cause) {
|
|
990
|
+
return Promise.reject(cause)
|
|
991
|
+
}
|
|
624
992
|
}
|
|
625
993
|
|
|
626
994
|
private save(): void {
|
|
627
|
-
const
|
|
995
|
+
const file = this.file!
|
|
996
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
628
997
|
writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 })
|
|
629
998
|
chmodSync(tmp, 0o600)
|
|
630
|
-
renameSync(tmp,
|
|
999
|
+
renameSync(tmp, file)
|
|
631
1000
|
}
|
|
632
1001
|
}
|
|
633
1002
|
|
|
634
|
-
/**
|
|
635
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* Idempotent upgrade of older states: the local user exists, every machine and device has an owner, agent data
|
|
1005
|
+
* and token epochs are valid.
|
|
1006
|
+
*/
|
|
1007
|
+
function migrate(state: StateData, t: number, options: Pick<StoreOptions, "mode">): void {
|
|
636
1008
|
let local = state.users.find((u) => u.id === LOCAL_USER)
|
|
637
1009
|
if (!local) {
|
|
638
1010
|
local = { id: LOCAL_USER, role: "admin", created: t, lastSeen: t }
|
|
@@ -646,8 +1018,19 @@ function migrate(state: StateData, t: number, options: StoreOptions): void {
|
|
|
646
1018
|
for (const record of [...state.machines, ...state.devices]) {
|
|
647
1019
|
if (typeof record.ownerID !== "string" || !record.ownerID) record.ownerID = LOCAL_USER
|
|
648
1020
|
}
|
|
649
|
-
//
|
|
1021
|
+
// Agent data and token epochs are optional: invalid values are normalised or dropped (epochs read as 0).
|
|
1022
|
+
for (const user of state.users) {
|
|
1023
|
+
if ("agent" in user) {
|
|
1024
|
+
const agent = normaliseAgentData(user.agent)
|
|
1025
|
+
if (agent) user.agent = agent
|
|
1026
|
+
else delete user.agent
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
for (const record of [...state.users, ...state.machines]) {
|
|
1030
|
+
if ("tokenEpoch" in record && !validEpoch(record.tokenEpoch)) delete record.tokenEpoch
|
|
1031
|
+
}
|
|
1032
|
+
// 0.5.0 ({handoff, blocked, finished, say}) and 0.7 (finished = every turn) push prefs → the current toggles.
|
|
650
1033
|
for (const device of state.devices) {
|
|
651
|
-
if (device.pushPrefs
|
|
1034
|
+
if (legacyPushPrefs(device.pushPrefs)) device.pushPrefs = normalizePushPrefs(device.pushPrefs)
|
|
652
1035
|
}
|
|
653
1036
|
}
|