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