@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
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// Webhooks (P4-T10; design §6.1, §12.2, §12.4; decision znbg8xa5 #8): the hub POSTs events to URLs that users and
|
|
2
|
+
// app tokens register.
|
|
3
|
+
//
|
|
4
|
+
// - Registrations per user in the StateStore collection "webhooks" (key: the id `wh_<16 hex>`): url, events
|
|
5
|
+
// (exact types or `launch.*`), an optional label, `by` (`user`, or `app:<token id>` for an app token's own) and
|
|
6
|
+
// the signing secret `whsec_…`, returned only by register. Users see and manage all of theirs; an app token only
|
|
7
|
+
// its own. An app registration gets only events of what that app started (`by` of the event), and is dropped
|
|
8
|
+
// once its token is revoked or loses the scope (appAlive).
|
|
9
|
+
// - URLs: https on a public host (publicURLProblem), checked at registration and again, re-resolved, right before
|
|
10
|
+
// every send (publicFetch: no redirects, timeout, bounded response read). hub.json agents.webhooks.allowPrivate
|
|
11
|
+
// admits http and private targets (self-hosted receivers).
|
|
12
|
+
// - Durable outbox in the StateStore collection "webhook-outbox", key `<webhook id>.<event id>`: at least once,
|
|
13
|
+
// deduplicated by event id (the first write wins, ifVersion 0), retried with backoff (backoffMs; then failed),
|
|
14
|
+
// one sender per delivery under a lease (Locks), so several hubs can drain the same store. Finished
|
|
15
|
+
// deliveries stay keepMs (GET …/:id shows them), then are pruned.
|
|
16
|
+
// - Each POST: the event JSON, Hub-Signature `t=<unix s>,v1=<hex HMAC-SHA256 of "<t>.<body>">`, Hub-Event (its
|
|
17
|
+
// type) and Hub-Delivery (stable across retries), per packages/protocol/src/webhook.ts.
|
|
18
|
+
import { createHash, createHmac, randomBytes } from "node:crypto"
|
|
19
|
+
import {
|
|
20
|
+
formatWebhookSignature,
|
|
21
|
+
WEBHOOK_DELIVERY_HEADER,
|
|
22
|
+
WEBHOOK_EVENT_HEADER,
|
|
23
|
+
WEBHOOK_SIGNATURE_ALGORITHM,
|
|
24
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
25
|
+
webhookSignedPayload,
|
|
26
|
+
} from "../../protocol"
|
|
27
|
+
import type { Clock, Locks, StateStore, Timers } from "../backend/types"
|
|
28
|
+
import { publicFetch, PublicURLError, publicURLProblem, type ResolveHost } from "../push-target"
|
|
29
|
+
import { AgentError } from "./errors"
|
|
30
|
+
import { LAUNCH_STATES, type Launch } from "./launch-store"
|
|
31
|
+
|
|
32
|
+
export const WEBHOOKS = "webhooks"
|
|
33
|
+
export const WEBHOOK_OUTBOX = "webhook-outbox"
|
|
34
|
+
/** Every event type a webhook can receive (phase 4: launch changes). */
|
|
35
|
+
export const WEBHOOK_EVENT_TYPES: readonly string[] = LAUNCH_STATES.map((state) => `launch.${state}`)
|
|
36
|
+
export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
|
|
37
|
+
export const MAX_WEBHOOKS = 20
|
|
38
|
+
export const MAX_WEBHOOK_URL = 2048
|
|
39
|
+
export const MAX_WEBHOOK_LABEL = 100
|
|
40
|
+
export const MAX_WEBHOOK_EVENTS = 20
|
|
41
|
+
/** Waits between attempts: 7 attempts over about 8.5 hours. */
|
|
42
|
+
export const WEBHOOK_BACKOFF_MS: readonly number[] = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000]
|
|
43
|
+
export const WEBHOOK_TIMEOUT_MS = 10_000
|
|
44
|
+
/** Bytes of a receiver's response the hub reads. */
|
|
45
|
+
export const WEBHOOK_RESPONSE_MAX = 64 * 1024
|
|
46
|
+
export const WEBHOOK_KEEP_MS = 86_400_000
|
|
47
|
+
/** Deliveries per user in the outbox; past it new events are dropped (logged). */
|
|
48
|
+
export const WEBHOOK_OUTBOX_MAX = 5000
|
|
49
|
+
const POLL_MS = 5000
|
|
50
|
+
const CONCURRENCY = 8
|
|
51
|
+
|
|
52
|
+
/** An event as webhooks carry it (CloudEvents-shaped, design §12.2). */
|
|
53
|
+
export interface WebhookEvent {
|
|
54
|
+
readonly id: string
|
|
55
|
+
readonly type: string
|
|
56
|
+
/** The hub's public URL. */
|
|
57
|
+
readonly source: string
|
|
58
|
+
readonly subject: string
|
|
59
|
+
/** ISO 8601. */
|
|
60
|
+
readonly time: string
|
|
61
|
+
readonly user: string
|
|
62
|
+
readonly schemaVersion: 1
|
|
63
|
+
readonly data: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Who a registration belongs to: the user, or one of their app tokens. */
|
|
67
|
+
export type WebhookOwner = "user" | `app:${string}`
|
|
68
|
+
|
|
69
|
+
interface Registration {
|
|
70
|
+
readonly id: string
|
|
71
|
+
readonly url: string
|
|
72
|
+
readonly events: readonly string[]
|
|
73
|
+
readonly by: WebhookOwner
|
|
74
|
+
readonly label?: string
|
|
75
|
+
readonly created: number
|
|
76
|
+
readonly secret: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A registration as the API shows it (never its secret). */
|
|
80
|
+
export type WebhookView = Omit<Registration, "secret">
|
|
81
|
+
|
|
82
|
+
export type DeliveryState = "pending" | "delivered" | "failed"
|
|
83
|
+
|
|
84
|
+
export interface DeliveryAttempt {
|
|
85
|
+
readonly at: number
|
|
86
|
+
readonly status?: number
|
|
87
|
+
readonly error?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface OutboxEntry {
|
|
91
|
+
/** The Hub-Delivery header. */
|
|
92
|
+
readonly id: string
|
|
93
|
+
readonly webhook: string
|
|
94
|
+
readonly event: WebhookEvent
|
|
95
|
+
readonly state: DeliveryState
|
|
96
|
+
readonly attempts: number
|
|
97
|
+
readonly created: number
|
|
98
|
+
readonly nextAt: number
|
|
99
|
+
readonly finishedAt?: number
|
|
100
|
+
readonly last?: DeliveryAttempt
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DeliveryView {
|
|
104
|
+
readonly id: string
|
|
105
|
+
readonly event: string
|
|
106
|
+
readonly type: string
|
|
107
|
+
readonly state: DeliveryState
|
|
108
|
+
readonly attempts: number
|
|
109
|
+
readonly created: number
|
|
110
|
+
readonly nextAt?: number
|
|
111
|
+
readonly last?: DeliveryAttempt
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RegisterInput {
|
|
115
|
+
readonly url?: unknown
|
|
116
|
+
readonly events?: unknown
|
|
117
|
+
readonly label?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface WebhooksDeps {
|
|
121
|
+
readonly state: StateStore
|
|
122
|
+
/** One sender per delivery across hubs; without it, this process is the only sender. */
|
|
123
|
+
readonly locks?: Locks
|
|
124
|
+
readonly now?: Clock
|
|
125
|
+
readonly timers?: Timers
|
|
126
|
+
/** The events' source (the hub's public URL). */
|
|
127
|
+
readonly source: string
|
|
128
|
+
/** Every user ID (the sender drains each user's outbox). */
|
|
129
|
+
readonly users: () => readonly string[]
|
|
130
|
+
readonly resolve: ResolveHost
|
|
131
|
+
/** hub.json agents.webhooks.allowPrivate. */
|
|
132
|
+
readonly allowPrivate?: boolean
|
|
133
|
+
readonly fetch?: typeof globalThis.fetch
|
|
134
|
+
/** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
|
|
135
|
+
readonly appAlive?: (userID: string, tokenID: string) => boolean
|
|
136
|
+
readonly backoffMs?: readonly number[]
|
|
137
|
+
readonly timeoutMs?: number
|
|
138
|
+
readonly maxResponseBytes?: number
|
|
139
|
+
readonly keepMs?: number
|
|
140
|
+
readonly maxWebhooks?: number
|
|
141
|
+
readonly maxOutbox?: number
|
|
142
|
+
readonly pollMs?: number
|
|
143
|
+
/** The lease owner (unique per hub process). */
|
|
144
|
+
readonly owner?: string
|
|
145
|
+
readonly userAgent?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const defaultTimers: Timers = {
|
|
149
|
+
set: (fn, ms) => {
|
|
150
|
+
const handle = setTimeout(fn, ms)
|
|
151
|
+
;(handle as { unref?: () => void }).unref?.()
|
|
152
|
+
return handle
|
|
153
|
+
},
|
|
154
|
+
clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const hex = (text: string, length: number) => createHash("sha256").update(text).digest("hex").slice(0, length)
|
|
158
|
+
const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
|
|
159
|
+
|
|
160
|
+
/** Whether a registration's pattern (an exact type or `<prefix>.*`) covers an event type. */
|
|
161
|
+
function matches(pattern: string, type: string): boolean {
|
|
162
|
+
return pattern.endsWith(".*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function eventsOf(raw: unknown): string[] {
|
|
166
|
+
if (raw === undefined) return [...DEFAULT_WEBHOOK_EVENTS]
|
|
167
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_WEBHOOK_EVENTS || !raw.every((e): e is string => typeof e === "string")) {
|
|
168
|
+
throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*"]`)
|
|
169
|
+
}
|
|
170
|
+
for (const pattern of raw) {
|
|
171
|
+
if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, `Use launch.* or one of ${WEBHOOK_EVENT_TYPES.join(", ")}`)
|
|
172
|
+
}
|
|
173
|
+
return [...new Set(raw)]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
|
|
177
|
+
|
|
178
|
+
const deliveryView = (entry: OutboxEntry): DeliveryView => ({
|
|
179
|
+
id: entry.id,
|
|
180
|
+
event: entry.event.id,
|
|
181
|
+
type: entry.event.type,
|
|
182
|
+
state: entry.state,
|
|
183
|
+
attempts: entry.attempts,
|
|
184
|
+
created: entry.created,
|
|
185
|
+
...(entry.state === "pending" ? { nextAt: entry.nextAt } : {}),
|
|
186
|
+
...(entry.last ? { last: entry.last } : {}),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
/** The webhook event for a launch change: `launch.<state>`, its id stable per step (launch, state, time). */
|
|
190
|
+
export function launchEvent(source: string, launch: Launch, data: unknown): WebhookEvent {
|
|
191
|
+
return {
|
|
192
|
+
id: `evt_${hex(`${launch.id}\u0000${launch.state}\u0000${launch.at}`, 24)}`,
|
|
193
|
+
type: `launch.${launch.state}`,
|
|
194
|
+
source,
|
|
195
|
+
subject: launch.id,
|
|
196
|
+
time: new Date(launch.at).toISOString(),
|
|
197
|
+
user: launch.owner,
|
|
198
|
+
schemaVersion: 1,
|
|
199
|
+
data: { launch: data },
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The hex HMAC-SHA256 over `<t>.<body>` (Hub-Signature v1). */
|
|
204
|
+
export function signWebhook(secret: string, t: number, body: string): string {
|
|
205
|
+
return createHmac(WEBHOOK_SIGNATURE_ALGORITHM, secret).update(webhookSignedPayload(t, body)).digest("hex")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export class Webhooks {
|
|
209
|
+
private readonly now: Clock
|
|
210
|
+
private readonly timers: Timers
|
|
211
|
+
private readonly backoffMs: readonly number[]
|
|
212
|
+
private readonly owner: string
|
|
213
|
+
private running: Promise<void> | undefined
|
|
214
|
+
private again = false
|
|
215
|
+
private started = false
|
|
216
|
+
private poll: unknown
|
|
217
|
+
private kickTimer: unknown
|
|
218
|
+
|
|
219
|
+
constructor(private readonly deps: WebhooksDeps) {
|
|
220
|
+
this.now = deps.now ?? Date.now
|
|
221
|
+
this.timers = deps.timers ?? defaultTimers
|
|
222
|
+
this.backoffMs = deps.backoffMs ?? WEBHOOK_BACKOFF_MS
|
|
223
|
+
this.owner = deps.owner ?? `hub-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async register(userID: string, by: WebhookOwner, input: RegisterInput): Promise<{ webhook: WebhookView; secret: string }> {
|
|
227
|
+
const { url } = input
|
|
228
|
+
if (typeof url !== "string" || url.length === 0) throw invalid("url must be a string")
|
|
229
|
+
if (url.length > MAX_WEBHOOK_URL) throw invalid(`url must be at most ${MAX_WEBHOOK_URL} characters`)
|
|
230
|
+
const problem = await publicURLProblem(url, { resolve: this.deps.resolve, label: "Webhook URL", allowPrivate: this.deps.allowPrivate === true })
|
|
231
|
+
if (problem) throw invalid(problem, this.deps.allowPrivate ? undefined : "Webhooks need an https URL on a public host")
|
|
232
|
+
const events = eventsOf(input.events)
|
|
233
|
+
let label: string | undefined
|
|
234
|
+
if (input.label !== undefined && input.label !== null) {
|
|
235
|
+
if (typeof input.label !== "string") throw invalid("label must be a string")
|
|
236
|
+
label = input.label.trim() || undefined
|
|
237
|
+
if (label && label.length > MAX_WEBHOOK_LABEL) throw invalid(`label must be at most ${MAX_WEBHOOK_LABEL} characters`)
|
|
238
|
+
}
|
|
239
|
+
const max = this.deps.maxWebhooks ?? MAX_WEBHOOKS
|
|
240
|
+
if ((await this.deps.state.list(userID, WEBHOOKS)).length >= max) throw new AgentError("rate_limited", `At most ${max} webhooks per user`, { hint: "Delete unused webhooks" })
|
|
241
|
+
const registration: Registration = {
|
|
242
|
+
id: `wh_${randomBytes(8).toString("hex")}`,
|
|
243
|
+
url,
|
|
244
|
+
events,
|
|
245
|
+
by,
|
|
246
|
+
...(label ? { label } : {}),
|
|
247
|
+
created: this.now(),
|
|
248
|
+
secret: `whsec_${randomBytes(32).toString("base64url")}`,
|
|
249
|
+
}
|
|
250
|
+
const written = await this.deps.state.put(userID, WEBHOOKS, registration.id, registration, { ifVersion: 0 })
|
|
251
|
+
if (!written.ok) throw new Error("webhook id collision")
|
|
252
|
+
return { webhook: view(registration), secret: registration.secret }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The user's registrations (by: only that owner's), by id. */
|
|
256
|
+
async list(userID: string, by?: WebhookOwner): Promise<WebhookView[]> {
|
|
257
|
+
return (await this.registrations(userID)).filter((r) => by === undefined || r.by === by).map(view)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async get(userID: string, id: string, by?: WebhookOwner): Promise<WebhookView | undefined> {
|
|
261
|
+
const registration = await this.registration(userID, id, by)
|
|
262
|
+
return registration && view(registration)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Deletes a registration and its outbox; false when it is not the caller's. */
|
|
266
|
+
async remove(userID: string, id: string, by?: WebhookOwner): Promise<boolean> {
|
|
267
|
+
if (!(await this.registration(userID, id, by))) return false
|
|
268
|
+
await this.deps.state.delete(userID, WEBHOOKS, id)
|
|
269
|
+
for (const entry of await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX)) {
|
|
270
|
+
if (entry.value.webhook === id) await this.deps.state.delete(userID, WEBHOOK_OUTBOX, entry.key)
|
|
271
|
+
}
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A registration's deliveries still kept, oldest first. */
|
|
276
|
+
async deliveries(userID: string, id: string): Promise<DeliveryView[]> {
|
|
277
|
+
return (await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX))
|
|
278
|
+
.map((e) => e.value)
|
|
279
|
+
.filter((e) => e.webhook === id)
|
|
280
|
+
.sort((a, b) => a.created - b.created || a.id.localeCompare(b.id))
|
|
281
|
+
.map(deliveryView)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Queues an event for every matching registration of the user: user registrations always, app registrations
|
|
286
|
+
* only when `by` (who caused it) is that app. Resolves with how many deliveries were newly queued (an event
|
|
287
|
+
* already queued for a registration is not queued again).
|
|
288
|
+
*/
|
|
289
|
+
async publish(userID: string, event: WebhookEvent, options: { readonly by?: string } = {}): Promise<number> {
|
|
290
|
+
const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by) && r.events.some((p) => matches(p, event.type)))
|
|
291
|
+
if (!targets.length) return 0
|
|
292
|
+
const max = this.deps.maxOutbox ?? WEBHOOK_OUTBOX_MAX
|
|
293
|
+
let size = (await this.deps.state.list(userID, WEBHOOK_OUTBOX)).length
|
|
294
|
+
let queued = 0
|
|
295
|
+
for (const registration of targets) {
|
|
296
|
+
if (size >= max) {
|
|
297
|
+
console.error(`[hub] webhook outbox of ${userID} is full; event ${event.id} dropped for ${registration.id}`)
|
|
298
|
+
break
|
|
299
|
+
}
|
|
300
|
+
const key = `${registration.id}.${event.id}`
|
|
301
|
+
const entry: OutboxEntry = { id: `whd_${hex(key, 24)}`, webhook: registration.id, event, state: "pending", attempts: 0, created: this.now(), nextAt: this.now() }
|
|
302
|
+
if ((await this.deps.state.put(userID, WEBHOOK_OUTBOX, key, entry, { ifVersion: 0 })).ok) {
|
|
303
|
+
queued++
|
|
304
|
+
size++
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (queued) this.kick()
|
|
308
|
+
return queued
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A launch change (the LaunchFeed): `data` is the launch as the API shows it. */
|
|
312
|
+
async onLaunch(userID: string, launch: Launch, data: unknown): Promise<number> {
|
|
313
|
+
return this.publish(userID, launchEvent(this.deps.source, launch, data), launch.by !== undefined ? { by: launch.by } : {})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Starts the sender: drains now and every pollMs, and soon after each publish. */
|
|
317
|
+
start(): void {
|
|
318
|
+
if (this.started) return
|
|
319
|
+
this.started = true
|
|
320
|
+
const tick = () => {
|
|
321
|
+
this.poll = this.timers.set(() => {
|
|
322
|
+
void this.drain()
|
|
323
|
+
.catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`))
|
|
324
|
+
.finally(() => this.started && tick())
|
|
325
|
+
}, this.deps.pollMs ?? POLL_MS)
|
|
326
|
+
}
|
|
327
|
+
this.kick()
|
|
328
|
+
tick()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
stop(): void {
|
|
332
|
+
this.started = false
|
|
333
|
+
if (this.poll !== undefined) this.timers.clear(this.poll)
|
|
334
|
+
if (this.kickTimer !== undefined) this.timers.clear(this.kickTimer)
|
|
335
|
+
this.poll = this.kickTimer = undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Sends every due delivery once (and prunes finished ones); concurrent calls share one run. */
|
|
339
|
+
drain(): Promise<void> {
|
|
340
|
+
if (this.running) {
|
|
341
|
+
this.again = true
|
|
342
|
+
return this.running
|
|
343
|
+
}
|
|
344
|
+
this.running = (async () => {
|
|
345
|
+
do {
|
|
346
|
+
this.again = false
|
|
347
|
+
for (const userID of this.deps.users()) await this.drainUser(userID)
|
|
348
|
+
} while (this.again)
|
|
349
|
+
})().finally(() => {
|
|
350
|
+
this.running = undefined
|
|
351
|
+
})
|
|
352
|
+
return this.running
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private kick(): void {
|
|
356
|
+
if (!this.started || this.kickTimer !== undefined) return
|
|
357
|
+
this.kickTimer = this.timers.set(() => {
|
|
358
|
+
this.kickTimer = undefined
|
|
359
|
+
void this.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`))
|
|
360
|
+
}, 0)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async registrations(userID: string): Promise<Registration[]> {
|
|
364
|
+
return (await this.deps.state.list<Registration>(userID, WEBHOOKS)).map((e) => e.value).sort((a, b) => a.id.localeCompare(b.id))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async registration(userID: string, id: string, by?: WebhookOwner): Promise<Registration | undefined> {
|
|
368
|
+
const found = (await this.deps.state.get<Registration>(userID, WEBHOOKS, id))?.value
|
|
369
|
+
return found && (by === undefined || found.by === by) ? found : undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async drainUser(userID: string): Promise<void> {
|
|
373
|
+
const now = this.now()
|
|
374
|
+
const keep = this.deps.keepMs ?? WEBHOOK_KEEP_MS
|
|
375
|
+
const due: string[] = []
|
|
376
|
+
for (const { key, value, version } of await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX)) {
|
|
377
|
+
if (value.state !== "pending") {
|
|
378
|
+
if (now - (value.finishedAt ?? value.created) > keep) await this.deps.state.delete(userID, WEBHOOK_OUTBOX, key, { ifVersion: version })
|
|
379
|
+
} else if (value.nextAt <= now) due.push(key)
|
|
380
|
+
}
|
|
381
|
+
for (let i = 0; i < due.length; i += CONCURRENCY) await Promise.all(due.slice(i, i + CONCURRENCY).map((key) => this.attempt(userID, key)))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** One attempt at one delivery, under its lease. */
|
|
385
|
+
private async attempt(userID: string, key: string): Promise<void> {
|
|
386
|
+
const timeoutMs = this.deps.timeoutMs ?? WEBHOOK_TIMEOUT_MS
|
|
387
|
+
const lockKey = `webhook:${userID}:${key}`
|
|
388
|
+
const { locks } = this.deps
|
|
389
|
+
if (locks && !(await locks.acquire(lockKey, this.owner, timeoutMs + 30_000))) return
|
|
390
|
+
try {
|
|
391
|
+
const current = await this.deps.state.get<OutboxEntry>(userID, WEBHOOK_OUTBOX, key)
|
|
392
|
+
const now = this.now()
|
|
393
|
+
if (!current || current.value.state !== "pending" || current.value.nextAt > now) return
|
|
394
|
+
const entry = current.value
|
|
395
|
+
const registration = await this.registration(userID, entry.webhook)
|
|
396
|
+
if (!registration) {
|
|
397
|
+
await this.deps.state.delete(userID, WEBHOOK_OUTBOX, key)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
if (registration.by !== "user" && this.deps.appAlive && !this.deps.appAlive(userID, registration.by.slice("app:".length))) {
|
|
401
|
+
await this.remove(userID, registration.id)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
const { last, final } = await this.send(registration, entry, now, timeoutMs)
|
|
405
|
+
const attempts = entry.attempts + 1
|
|
406
|
+
const delivered = last.status !== undefined && last.status >= 200 && last.status < 300
|
|
407
|
+
const done = delivered || final || attempts > this.backoffMs.length
|
|
408
|
+
const next: OutboxEntry = done
|
|
409
|
+
? { ...entry, attempts, last, state: delivered ? "delivered" : "failed", finishedAt: this.now() }
|
|
410
|
+
: { ...entry, attempts, last, nextAt: now + this.backoffMs[attempts - 1]! }
|
|
411
|
+
await this.deps.state.put(userID, WEBHOOK_OUTBOX, key, next, { ifVersion: current.version })
|
|
412
|
+
} finally {
|
|
413
|
+
if (locks) await locks.release(lockKey, this.owner)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async send(registration: Registration, entry: OutboxEntry, now: number, timeoutMs: number): Promise<{ last: DeliveryAttempt; final: boolean }> {
|
|
418
|
+
const body = JSON.stringify(entry.event)
|
|
419
|
+
const t = Math.floor(now / 1000)
|
|
420
|
+
try {
|
|
421
|
+
const result = await publicFetch(
|
|
422
|
+
registration.url,
|
|
423
|
+
{
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: {
|
|
426
|
+
"content-type": "application/json",
|
|
427
|
+
"user-agent": this.deps.userAgent ?? "hub-webhooks",
|
|
428
|
+
[WEBHOOK_SIGNATURE_HEADER]: formatWebhookSignature(t, [signWebhook(registration.secret, t, body)]),
|
|
429
|
+
[WEBHOOK_EVENT_HEADER]: entry.event.type,
|
|
430
|
+
[WEBHOOK_DELIVERY_HEADER]: entry.id,
|
|
431
|
+
},
|
|
432
|
+
body,
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
resolve: this.deps.resolve,
|
|
436
|
+
label: "Webhook URL",
|
|
437
|
+
allowPrivate: this.deps.allowPrivate === true,
|
|
438
|
+
timeoutMs,
|
|
439
|
+
maxBytes: this.deps.maxResponseBytes ?? WEBHOOK_RESPONSE_MAX,
|
|
440
|
+
...(this.deps.fetch ? { fetch: this.deps.fetch } : {}),
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
return { last: { at: now, status: result.status }, final: false }
|
|
444
|
+
} catch (cause) {
|
|
445
|
+
const error = (cause instanceof Error ? cause.message : String(cause)).slice(0, 300)
|
|
446
|
+
return { last: { at: now, error }, final: cause instanceof PublicURLError }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Per-user agent control plane data stored on UserRecord.agent in state.json: session names, roles, session
|
|
2
|
+
// records and app/agent tokens (hashes only). Storage only: validation, normalisation and merging. Unknown
|
|
3
|
+
// fields (on the object and on each entry) are kept, so data written by a newer hub survives this one.
|
|
4
|
+
// It lives inside `users`, which older hubs persist as-is, so a downgrade keeps it.
|
|
5
|
+
// Lifecycle rules (release, alias expiry, offline role loss) are evaluated lazily by agent/directory.ts.
|
|
6
|
+
import { withSuffix } from "./agent/names"
|
|
7
|
+
|
|
8
|
+
/** `@name` → session. Unique per user by name (current names and aliases share one namespace). */
|
|
9
|
+
export interface AgentName {
|
|
10
|
+
name: string
|
|
11
|
+
sessionID: string
|
|
12
|
+
/** When it was set (ms). */
|
|
13
|
+
created?: number
|
|
14
|
+
/** Set on an alias (the name before a rename): it is released at this time (ms). */
|
|
15
|
+
expiresAt?: number
|
|
16
|
+
[field: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `@@role` holder. Unique per user by (role, project, sessionID). */
|
|
20
|
+
export interface AgentRole {
|
|
21
|
+
role: string
|
|
22
|
+
sessionID: string
|
|
23
|
+
/** Project scope (`role.project`); absent for an unscoped claim. */
|
|
24
|
+
project?: string
|
|
25
|
+
/** Holder generation: every new holder of (role, project) gets the next one. */
|
|
26
|
+
generation?: number
|
|
27
|
+
/** Claimed with --exclusive: nobody else may hold (role, project) while this holder is live. */
|
|
28
|
+
exclusive?: boolean
|
|
29
|
+
claimedAt?: number
|
|
30
|
+
[field: string]: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A session the control plane knows about. Unique per user by sessionID. */
|
|
34
|
+
export interface AgentSession {
|
|
35
|
+
sessionID: string
|
|
36
|
+
machineID?: string
|
|
37
|
+
parentID?: string
|
|
38
|
+
/** Project name (lowercased) used by `.project` scopes. */
|
|
39
|
+
project?: string
|
|
40
|
+
title?: string
|
|
41
|
+
firstSeen?: number
|
|
42
|
+
/** Last time a spoke reported it (ms; persisted at minute granularity while it stays online). */
|
|
43
|
+
lastSeen?: number
|
|
44
|
+
/** When the hub saw it archived (the console's Archive, viewPrefs.hiddenSessions). */
|
|
45
|
+
archivedAt?: number
|
|
46
|
+
deletedAt?: number
|
|
47
|
+
/** Name derivation already happened (derived once, from the first non-placeholder title). */
|
|
48
|
+
named?: boolean
|
|
49
|
+
/** When the one-time identity primer reached the session (ms); absent: not primed yet. */
|
|
50
|
+
primed?: number
|
|
51
|
+
[field: string]: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A user-created app/agent token: only the sha256 hash is stored. Unique per user by id. */
|
|
55
|
+
export interface AppToken {
|
|
56
|
+
id: string
|
|
57
|
+
tokenHash: string
|
|
58
|
+
label?: string
|
|
59
|
+
scopes?: string[]
|
|
60
|
+
created?: number
|
|
61
|
+
[field: string]: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AgentData {
|
|
65
|
+
names?: AgentName[]
|
|
66
|
+
roles?: AgentRole[]
|
|
67
|
+
sessions?: AgentSession[]
|
|
68
|
+
appTokens?: AppToken[]
|
|
69
|
+
/** Last holder generation handed out per roleKey(role, project). */
|
|
70
|
+
generations?: Record<string, number>
|
|
71
|
+
/** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
|
|
72
|
+
actingPaused?: true
|
|
73
|
+
[field: string]: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Key of a role and its project scope (for generations and exclusivity): `role` or `role.project` (a role never
|
|
78
|
+
* contains a dot). Never a NUL: a Postgres jsonb string cannot hold one (cluster mode).
|
|
79
|
+
*/
|
|
80
|
+
export const roleKey = (role: string, project?: string): string => (project ? `${role}.${project}` : role)
|
|
81
|
+
|
|
82
|
+
/** Generation keys of older hubs: role NUL project. */
|
|
83
|
+
const LEGACY_ROLE_KEY = "\u0000"
|
|
84
|
+
|
|
85
|
+
type Entry = Record<string, unknown>
|
|
86
|
+
|
|
87
|
+
/** Each list: the string fields an entry needs, and its identity (entries with the same one are duplicates). */
|
|
88
|
+
const LISTS: Record<"names" | "roles" | "sessions" | "appTokens", { required: readonly string[]; identity: (e: Entry) => string }> = {
|
|
89
|
+
names: { required: ["name", "sessionID"], identity: (e) => e.name as string },
|
|
90
|
+
roles: { required: ["role", "sessionID"], identity: (e) => `${roleKey(e.role as string, typeof e.project === "string" ? e.project : undefined)}\u0000${e.sessionID}` },
|
|
91
|
+
sessions: { required: ["sessionID"], identity: (e) => e.sessionID as string },
|
|
92
|
+
appTokens: { required: ["id", "tokenHash"], identity: (e) => e.id as string },
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isPlain = (v: unknown): v is Entry => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
96
|
+
const isList = (key: string): key is keyof typeof LISTS => Object.hasOwn(LISTS, key)
|
|
97
|
+
|
|
98
|
+
/** Valid entries of a list, first of each identity kept. */
|
|
99
|
+
function normaliseList(key: keyof typeof LISTS, value: readonly unknown[]): Entry[] {
|
|
100
|
+
const { required, identity } = LISTS[key]
|
|
101
|
+
const seen = new Set<string>()
|
|
102
|
+
const out: Entry[] = []
|
|
103
|
+
for (const entry of value) {
|
|
104
|
+
if (!isPlain(entry) || !required.every((field) => typeof entry[field] === "string" && entry[field] !== "")) continue
|
|
105
|
+
const id = identity(entry)
|
|
106
|
+
if (seen.has(id)) continue
|
|
107
|
+
seen.add(id)
|
|
108
|
+
out.push(entry)
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Generation counters: non-negative safe integers only. */
|
|
114
|
+
function normaliseGenerations(value: Entry): Record<string, number> {
|
|
115
|
+
const out: Record<string, number> = {}
|
|
116
|
+
for (const [raw, n] of Object.entries(value)) {
|
|
117
|
+
if (!validEpoch(n)) continue
|
|
118
|
+
const legacy = raw.indexOf(LEGACY_ROLE_KEY)
|
|
119
|
+
const key = legacy < 0 ? raw : roleKey(raw.slice(0, legacy), raw.slice(legacy + 1))
|
|
120
|
+
out[key] = Math.max(out[key] ?? 0, n)
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Agent data from a stored or updated value: undefined when it is not an object; a known list that is not an
|
|
127
|
+
* array is dropped; invalid and duplicate entries are dropped. Idempotent; key order is kept.
|
|
128
|
+
*/
|
|
129
|
+
export function normaliseAgentData(value: unknown): AgentData | undefined {
|
|
130
|
+
if (!isPlain(value)) return undefined
|
|
131
|
+
const out: Entry = {}
|
|
132
|
+
for (const [key, item] of Object.entries(value)) {
|
|
133
|
+
if (key === "generations") {
|
|
134
|
+
if (isPlain(item)) out[key] = normaliseGenerations(item)
|
|
135
|
+
} else if (!isList(key)) out[key] = item
|
|
136
|
+
else if (Array.isArray(item)) out[key] = normaliseList(key, item)
|
|
137
|
+
}
|
|
138
|
+
return out as AgentData
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `from`'s names after `have`: a name `have` holds for another session gets a suffix (-2, -3, …). */
|
|
142
|
+
function mergeNames(have: Entry[], from: readonly unknown[]): Entry[] {
|
|
143
|
+
const out = normaliseList("names", have)
|
|
144
|
+
const owner = new Map(out.map((e) => [e.name as string, e.sessionID as string]))
|
|
145
|
+
for (const entry of normaliseList("names", from)) {
|
|
146
|
+
const held = owner.get(entry.name as string)
|
|
147
|
+
if (held === entry.sessionID) continue
|
|
148
|
+
const name = held === undefined ? (entry.name as string) : withSuffix(entry.name as string, (n) => owner.has(n))
|
|
149
|
+
owner.set(name, entry.sessionID as string)
|
|
150
|
+
out.push({ ...entry, name })
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `into` plus what `from` adds: list entries with a new identity, other fields `into` lacks. A name of
|
|
157
|
+
* `from` that `into` holds for a different session is kept with a suffix; generations take the higher counter.
|
|
158
|
+
*/
|
|
159
|
+
export function mergeAgentData(into: AgentData, from: AgentData): AgentData {
|
|
160
|
+
const out: Entry = { ...into }
|
|
161
|
+
for (const [key, item] of Object.entries(from)) {
|
|
162
|
+
if (key === "names" && Array.isArray(item)) {
|
|
163
|
+
out[key] = mergeNames(Array.isArray(out[key]) ? (out[key] as Entry[]) : [], item)
|
|
164
|
+
} else if (key === "generations" && isPlain(item)) {
|
|
165
|
+
const merged = normaliseGenerations(isPlain(out[key]) ? (out[key] as Entry) : {})
|
|
166
|
+
for (const [k, n] of Object.entries(normaliseGenerations(item))) merged[k] = Math.max(merged[k] ?? 0, n)
|
|
167
|
+
out[key] = merged
|
|
168
|
+
} else if (isList(key) && Array.isArray(item)) {
|
|
169
|
+
const have = Array.isArray(out[key]) ? (out[key] as Entry[]) : []
|
|
170
|
+
out[key] = normaliseList(key, [...have, ...item])
|
|
171
|
+
} else if (!(key in out)) out[key] = item
|
|
172
|
+
}
|
|
173
|
+
return out as AgentData
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A stored token epoch: a non-negative safe integer, else absent (read as 0). */
|
|
177
|
+
export const validEpoch = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0
|