@huynle/hub 0.8.7 → 0.9.0
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 +129 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
package/src/central/http.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { diskAssets, type WebAssets } from "./assets"
|
|
|
8
8
|
import path from "node:path"
|
|
9
9
|
import { promisify } from "node:util"
|
|
10
10
|
import { brotliCompress, constants as zlib, gzip } from "node:zlib"
|
|
11
|
-
import { formAnswerProblem, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
|
|
11
|
+
import { formAnswerProblem, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor } from "../protocol"
|
|
12
12
|
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
13
13
|
import { speakable } from "../speakable"
|
|
14
14
|
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
@@ -16,12 +16,17 @@ import { emailAllowed, type CentralConfig } from "./config"
|
|
|
16
16
|
import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
|
|
17
17
|
import { isNotificationKind, Push, type PushSender } from "./push"
|
|
18
18
|
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
19
|
+
import { createFetcher, parseDenyCidrs, type Fetcher } from "./fetcher"
|
|
20
|
+
import { createRemoteMedia } from "./media-remote"
|
|
21
|
+
import { createUnfurl } from "./media-unfurl"
|
|
22
|
+
import { createFetchBudget } from "./fetch-budget"
|
|
23
|
+
import { createTimelineMedia } from "./media-timeline"
|
|
19
24
|
import { Webhooks } from "./agent/webhooks"
|
|
20
25
|
import { issueAppToken } from "./agent/app-tokens"
|
|
21
26
|
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent, conciergeEvent, type AgentEvent } from "./agent/events"
|
|
22
27
|
import { EventWriters } from "./agent/event-writers"
|
|
23
28
|
import { MemoryEventLog } from "./backend/memory-events"
|
|
24
|
-
import {
|
|
29
|
+
import { WEBHOOK_APP_SCOPES } from "./agent/webhook-routes"
|
|
25
30
|
import { hubBackends, type Backends } from "./backend"
|
|
26
31
|
import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
|
|
27
32
|
import { ActingSwitch } from "./agent/acting-switch"
|
|
@@ -45,7 +50,7 @@ import { SessionTokens } from "./agent/session-tokens"
|
|
|
45
50
|
import { SessionTokenPush } from "./session-token-push"
|
|
46
51
|
import { TokenKeys } from "./token-keys"
|
|
47
52
|
import { EventLog } from "./events-log"
|
|
48
|
-
import { BusyError, Registry } from "./registry"
|
|
53
|
+
import { BusyError, Registry, type Timers } from "./registry"
|
|
49
54
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
50
55
|
import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
|
|
51
56
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
@@ -78,6 +83,8 @@ import { recordingRoutes } from "./recording/routes"
|
|
|
78
83
|
import { wrapConciergeSend } from "./recording/concierge-taps"
|
|
79
84
|
import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
|
|
80
85
|
import { ArtifactStore } from "./artifacts/store"
|
|
86
|
+
import { messageTexts, pickHtmlBlock, PREVIEW_LIMITS } from "./artifacts/previews"
|
|
87
|
+
import { BundleProtocolError, fetchPreviewBundle } from "./artifacts/preview-bundle"
|
|
81
88
|
import { artifactHost, isArtifactHostPath } from "./artifacts/host"
|
|
82
89
|
import { CONSOLE_CSP, cspHeader } from "./csp"
|
|
83
90
|
import { ArtifactHostCaps } from "./artifacts/host-caps"
|
|
@@ -120,6 +127,10 @@ export interface Speech {
|
|
|
120
127
|
|
|
121
128
|
export interface CentralOptions {
|
|
122
129
|
readonly config: CentralConfig
|
|
130
|
+
/** HTML preview retention (default: archived 24 h after the last view, purged 7 days later). */
|
|
131
|
+
readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
|
|
132
|
+
/** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
|
|
133
|
+
readonly registryTimers?: Timers
|
|
123
134
|
/** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
|
|
124
135
|
readonly webDir?: string
|
|
125
136
|
readonly web?: WebAssets
|
|
@@ -134,6 +145,8 @@ export interface CentralOptions {
|
|
|
134
145
|
readonly pushSender?: PushSender
|
|
135
146
|
/** Resolves push endpoint and webhook hosts (SSRF checks); the system resolver by default. */
|
|
136
147
|
readonly resolveHost?: ResolveHost
|
|
148
|
+
/** The outbound fetcher of /api/media/remote and /api/media/unfurl (tests); default: resolveHost, the built-in deny list and HUB_FETCH_DENY_CIDRS. */
|
|
149
|
+
readonly remoteFetcher?: Fetcher
|
|
137
150
|
/** Clock for sign-in expiry, the store, the registry and the backends (tests). */
|
|
138
151
|
readonly now?: () => number
|
|
139
152
|
/** Shared state for new code (default: in memory, see ./backend). */
|
|
@@ -146,6 +159,8 @@ export interface CentralOptions {
|
|
|
146
159
|
readonly turnGraceMs?: number
|
|
147
160
|
/** `Prefer: respond-async` turns: how long the hub waits for a quick result before answering 202. */
|
|
148
161
|
readonly turnAcceptMs?: number
|
|
162
|
+
/** (S4 M3) The longest one timeline image may take from the spoke, all chunks together (default 60 s). */
|
|
163
|
+
readonly mediaDeadlineMs?: number
|
|
149
164
|
readonly heartbeatMs?: number
|
|
150
165
|
readonly staleMs?: number
|
|
151
166
|
/** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
|
|
@@ -162,6 +177,9 @@ export interface CentralOptions {
|
|
|
162
177
|
/** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
|
|
163
178
|
readonly drainTimeoutMs?: number
|
|
164
179
|
readonly drainRetryAfterMs?: number
|
|
180
|
+
/** Launches: the end watcher's settle and the engine's tick (see END_SETTLE_MS, END_TICK_MS; test fixtures). */
|
|
181
|
+
readonly launchSettleMs?: number
|
|
182
|
+
readonly launchTickMs?: number
|
|
165
183
|
}
|
|
166
184
|
}
|
|
167
185
|
|
|
@@ -288,6 +306,13 @@ interface Route extends RouteInfo {
|
|
|
288
306
|
|
|
289
307
|
const NO_STORE = { "cache-control": "no-store" }
|
|
290
308
|
|
|
309
|
+
/** Who asks for a timeline image, and how (If-None-Match, ?download=1). */
|
|
310
|
+
interface MediaCaller {
|
|
311
|
+
readonly request: Request
|
|
312
|
+
readonly url: URL
|
|
313
|
+
readonly userID: string
|
|
314
|
+
}
|
|
315
|
+
|
|
291
316
|
export const COOKIE = "voice_device"
|
|
292
317
|
const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
|
|
293
318
|
const SSE_HEARTBEAT_MS = 20_000
|
|
@@ -309,6 +334,8 @@ export const TURN_ACCEPT_MS = 1500
|
|
|
309
334
|
const NO_OWNER = "No OpenCode process for this session is connected"
|
|
310
335
|
/** A message ID in a fork or revert request (as the spoke protocol bounds it). */
|
|
311
336
|
const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
337
|
+
/** A message ID in a media URL: the same characters without `/`, and never `..` (it names a message, never a path). */
|
|
338
|
+
const MEDIA_MESSAGE_ID = /^(?!.*\.\.)[A-Za-z0-9_.:@+~-]{1,300}$/
|
|
312
339
|
const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
|
|
313
340
|
const NOT_EXPOSED = "Voice is not enabled for this session"
|
|
314
341
|
/** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
|
|
@@ -411,7 +438,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
411
438
|
? Store.shared({ state: backends.state, bus: backends.bus, now, instanceID: `${config.cluster.podID}-${randomUUID().slice(0, 8)}` })
|
|
412
439
|
: Store.open(config.stateDir, now, { mode: config.auth.mode })
|
|
413
440
|
// A machine's tenant is its owner (read live: ownership moves on migration).
|
|
414
|
-
const registry = new Registry(now,
|
|
441
|
+
const registry = new Registry(now, options.registryTimers, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
|
|
415
442
|
const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
|
|
416
443
|
const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
|
|
417
444
|
// CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
|
|
@@ -558,6 +585,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
558
585
|
// options.fetch is for speech providers only; sign-in always talks to the real issuer.
|
|
559
586
|
const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
|
|
560
587
|
/** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
|
|
588
|
+
const mediaSettingsView = (userID: string) => ({ allowlist: store.mediaAllowlist(userID), remoteFetch: config.remoteFetch !== false })
|
|
561
589
|
const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
|
|
562
590
|
const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
|
|
563
591
|
const tokenKeys = TokenKeys.open(config.stateDir)
|
|
@@ -631,6 +659,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
631
659
|
// Cluster mode: a catalog change stays on this pod (each pod's registry emits its own for remote slices); the rest
|
|
632
660
|
// goes to the tenant's streams on every pod.
|
|
633
661
|
registry.subscribe((message, tenant) => (live && message.type === "catalog" ? catalogHere() : broadcast({ ...message }, tenant !== undefined ? { tenant } : {})))
|
|
662
|
+
// (voice front P1) A front that failed is recorded with the session's recordings (the fallback is visible there).
|
|
663
|
+
registry.subscribe((message, tenant) => {
|
|
664
|
+
if (message.type === "conversation" && message.reason === "front_failed" && tenant !== undefined) {
|
|
665
|
+
recordings?.hub(tenant, message.sessionID, "conversation", { on: true, front: false, reason: "front_failed" })
|
|
666
|
+
}
|
|
667
|
+
})
|
|
634
668
|
registry.subscribeAlerts((alert) => {
|
|
635
669
|
// Push goes to the session owner's devices only; an alert without an owner goes nowhere.
|
|
636
670
|
if (!alert.owner) return
|
|
@@ -892,6 +926,24 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
892
926
|
}
|
|
893
927
|
})
|
|
894
928
|
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
929
|
+
// (S4 M3) Timeline images, cached per user on this pod; a deleted session or a committed revert forgets them.
|
|
930
|
+
const timelineMedia = createTimelineMedia({
|
|
931
|
+
request: (connID, req) => spokes.request(connID, req),
|
|
932
|
+
now,
|
|
933
|
+
...(options.timing?.mediaDeadlineMs !== undefined ? { deadlineMs: options.timing.mediaDeadlineMs } : {}),
|
|
934
|
+
})
|
|
935
|
+
const mediaRevertCommits = new Map<string, number>()
|
|
936
|
+
registry.onSessions((connID) => {
|
|
937
|
+
for (const { sessionID, committed } of registry.revertCommits(connID)) {
|
|
938
|
+
if (mediaRevertCommits.get(sessionID) === committed.at) continue
|
|
939
|
+
mediaRevertCommits.set(sessionID, committed.at)
|
|
940
|
+
timelineMedia.forgetSession(sessionID)
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
registry.onSessionDeleted((sessionID) => {
|
|
944
|
+
mediaRevertCommits.delete(sessionID)
|
|
945
|
+
timelineMedia.forgetSession(sessionID)
|
|
946
|
+
})
|
|
895
947
|
// (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
|
|
896
948
|
const intents = new SessionIntents({
|
|
897
949
|
state: backends.state,
|
|
@@ -951,6 +1003,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
951
1003
|
if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
|
|
952
1004
|
},
|
|
953
1005
|
intents,
|
|
1006
|
+
...(options.timing?.launchSettleMs !== undefined ? { settleMs: options.timing.launchSettleMs } : {}),
|
|
1007
|
+
...(options.timing?.launchTickMs !== undefined ? { tickMs: options.timing.launchTickMs } : {}),
|
|
954
1008
|
// FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
|
|
955
1009
|
launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
|
|
956
1010
|
profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
|
|
@@ -1007,7 +1061,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1007
1061
|
users: () => store.users().map((u) => u.id),
|
|
1008
1062
|
resolve: options.resolveHost ?? systemResolveHost,
|
|
1009
1063
|
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
1010
|
-
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes
|
|
1064
|
+
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && WEBHOOK_APP_SCOPES.some((scope) => t.scopes!.includes(scope))) === true,
|
|
1011
1065
|
appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
|
|
1012
1066
|
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
1013
1067
|
})
|
|
@@ -1052,6 +1106,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1052
1106
|
blobs: backends.blobs,
|
|
1053
1107
|
now,
|
|
1054
1108
|
workspaces: artifactWorkspacesFromState(backends.state),
|
|
1109
|
+
...(options.previewRetention?.archiveAfterMs !== undefined ? { previewArchiveAfterMs: options.previewRetention.archiveAfterMs } : {}),
|
|
1110
|
+
...(options.previewRetention?.purgeAfterMs !== undefined ? { previewPurgeAfterMs: options.previewRetention.purgeAfterMs } : {}),
|
|
1055
1111
|
onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
|
|
1056
1112
|
})
|
|
1057
1113
|
: undefined
|
|
@@ -1183,8 +1239,30 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1183
1239
|
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
|
|
1184
1240
|
registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
|
|
1185
1241
|
launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
|
|
1186
|
-
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
|
|
1187
|
-
|
|
1242
|
+
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
|
|
1243
|
+
// message inherits the item's notAfter (H4); queued items past it are cancelled by the team-intake leader's sweep.
|
|
1244
|
+
teamIntake = new TeamIntakeService({
|
|
1245
|
+
state: backends.state,
|
|
1246
|
+
teams: teamStore,
|
|
1247
|
+
seats: teamSeats,
|
|
1248
|
+
staffing: teamStaffing,
|
|
1249
|
+
members: workspaceMembers,
|
|
1250
|
+
presence: teamPresence,
|
|
1251
|
+
events: agentEvents,
|
|
1252
|
+
now,
|
|
1253
|
+
users: allUsers,
|
|
1254
|
+
deliver: async (userID, sessionID, text, options = {}) => ({
|
|
1255
|
+
messageID: (
|
|
1256
|
+
await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, {
|
|
1257
|
+
...(options.hop === undefined ? {} : { chainHop: options.hop }),
|
|
1258
|
+
...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
|
|
1259
|
+
})
|
|
1260
|
+
).message.id,
|
|
1261
|
+
}),
|
|
1262
|
+
hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }),
|
|
1263
|
+
})
|
|
1264
|
+
const intakeService = teamIntake
|
|
1265
|
+
void leader("team-intake", { onElected: () => intakeService.start(), onDeposed: () => intakeService.stop() }).start() // created after the other leaders were started
|
|
1188
1266
|
// Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
|
|
1189
1267
|
// supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
|
|
1190
1268
|
const workspaceCursors = journalCursors(tokenKeys, agentEvents)
|
|
@@ -1432,7 +1510,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1432
1510
|
onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
|
|
1433
1511
|
})
|
|
1434
1512
|
void conciergeLeader.start()
|
|
1435
|
-
const agentDeps = { recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
|
|
1513
|
+
const agentDeps = { recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
|
|
1436
1514
|
const resolveActor = createResolver({
|
|
1437
1515
|
cookieName: COOKIE,
|
|
1438
1516
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -1586,10 +1664,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1586
1664
|
store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
|
|
1587
1665
|
|
|
1588
1666
|
/** Session routes that also answer for the user's offline sessions the directory knows (hub-side data only). */
|
|
1589
|
-
const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages"])
|
|
1667
|
+
const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages", "/api/sessions/:session/previews"])
|
|
1590
1668
|
/** No spoke reports the session (for anyone) and the user's directory has a record of it (not deleted). */
|
|
1591
1669
|
const knownOffline = (userID: string, sessionID: string) =>
|
|
1592
1670
|
registry.sessionTenant(sessionID) === undefined && (store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt === undefined)
|
|
1671
|
+
// (RF1, RF3) Web images and link previews for the console through the safe fetcher (design §4): signed-in users
|
|
1672
|
+
// only, one fetcher and ONE per-user budget (30 a minute, 4 at once) for both routes together.
|
|
1673
|
+
const remoteFetcher = options.remoteFetcher ?? createFetcher({ resolve: options.resolveHost ?? systemResolveHost, denyCidrs: parseDenyCidrs((config.fetchDenyCidrs ?? []).join(",")) })
|
|
1674
|
+
const fetchBudget = createFetchBudget({ now })
|
|
1675
|
+
const remoteMedia = createRemoteMedia({ fetcher: remoteFetcher, enabled: config.remoteFetch !== false, now, audit: (entry) => store.audit(entry), budget: fetchBudget })
|
|
1676
|
+
const unfurl = createUnfurl({ fetcher: remoteFetcher, enabled: config.remoteFetch !== false, now, audit: (entry) => store.audit(entry), budget: fetchBudget })
|
|
1593
1677
|
const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
|
|
1594
1678
|
const keys: string[] = []
|
|
1595
1679
|
const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
|
|
@@ -1628,6 +1712,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1628
1712
|
}),
|
|
1629
1713
|
),
|
|
1630
1714
|
route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
|
|
1715
|
+
route("GET", "/api/media/remote", "user", ({ user, request, url }) => remoteMedia.handle(user.id, request, url)),
|
|
1716
|
+
route("GET", "/api/media/unfurl", "user", ({ user, request, url }) => unfurl.handle(user.id, request, url)),
|
|
1631
1717
|
route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
|
|
1632
1718
|
const shown = streamSessionsQuery(url)
|
|
1633
1719
|
if ("problem" in shown) return error(400, shown.problem)
|
|
@@ -1771,6 +1857,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1771
1857
|
conciergeTouch(user.id)
|
|
1772
1858
|
return Response.json(view, { headers: NO_STORE })
|
|
1773
1859
|
}),
|
|
1860
|
+
// (RF2) The user's media allowlist (Settings → Media) and whether this hub fetches web images at all.
|
|
1861
|
+
route("GET", "/api/settings/media", "user", ({ user }) => Response.json(mediaSettingsView(user.id), { headers: NO_STORE })),
|
|
1862
|
+
route("PUT", "/api/settings/media", "user", async ({ request, user }) => {
|
|
1863
|
+
await store.setMediaAllowlist(user.id, mediaSettingsInput(await jsonBody(request)))
|
|
1864
|
+
return Response.json(mediaSettingsView(user.id), { headers: NO_STORE })
|
|
1865
|
+
}),
|
|
1774
1866
|
route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
|
|
1775
1867
|
const kind = url.searchParams.get("kind")
|
|
1776
1868
|
if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
|
|
@@ -1803,7 +1895,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1803
1895
|
"/api/sessions/:session/interrupt",
|
|
1804
1896
|
"session",
|
|
1805
1897
|
async ({ params }) => {
|
|
1806
|
-
|
|
1898
|
+
// (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
|
|
1899
|
+
const front = registry.frontRoute(params.session!)
|
|
1900
|
+
// A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
|
|
1901
|
+
const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(params.session!), false)) : false
|
|
1902
|
+
if (!viaFront) await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
|
|
1807
1903
|
registry.cancelTurns(params.session!)
|
|
1808
1904
|
recordingTap(params.session!, "interrupt", {})
|
|
1809
1905
|
return new Response(null, { status: 204 })
|
|
@@ -1900,7 +1996,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1900
1996
|
// harnesses) never ships image bytes to the console in a tool detail.
|
|
1901
1997
|
route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
|
|
1902
1998
|
Response.json(redactEmbeddedMedia(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! })), { headers: NO_STORE })),
|
|
1903
|
-
route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params }) => timelineFile(params.session!, params.message!, params.index
|
|
1999
|
+
route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params, request, url, user }) => timelineFile(params.session!, params.message!, params.index!, { request, url, userID: user.id })),
|
|
2000
|
+
// (S4 M3) A timeline image by ref, pulled from the spoke in chunks (./media-timeline.ts; files/:index is an alias).
|
|
2001
|
+
route("GET", "/api/sessions/:session/timeline/messages/:message/media/:ref", "session", ({ params, request, url, user }) =>
|
|
2002
|
+
timelineMediaRoute({ request, url, userID: user.id }, params.session!, params.message!, params.ref!)),
|
|
2003
|
+
// (session media §3) An HTML preview of the message's n-th ```html block, read from the spoke, never the console.
|
|
2004
|
+
route("POST", "/api/sessions/:session/previews", "session", ({ params, request, user }) => createPreview(user.id, params.session!, request)),
|
|
1904
2005
|
route(
|
|
1905
2006
|
"GET",
|
|
1906
2007
|
"/api/sessions/:session/context",
|
|
@@ -2179,11 +2280,102 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2179
2280
|
return Response.json({ mode: "turn" }, { status: 202, headers: { "preference-applied": "respond-async" } })
|
|
2180
2281
|
}
|
|
2181
2282
|
|
|
2182
|
-
/**
|
|
2183
|
-
|
|
2283
|
+
/**
|
|
2284
|
+
* POST /api/sessions/:session/previews {messageID, block}: the message's `block`-th (0-based) ```html block, from
|
|
2285
|
+
* the spoke's timeline (newest-first pages), stored as a private preview artifact (ArtifactStore.savePreview: the
|
|
2286
|
+
* same content reuses its revision). Answers {artifact, revision, reused, host}: POST `host` (the artifact host
|
|
2287
|
+
* mint route) opens it. 404 no such message or block (code too_old: older than the newest PREVIEW_LIMITS.pages
|
|
2288
|
+
* timeline pages), 409 spoke offline, 413 over PREVIEW_LIMITS.htmlBytes.
|
|
2289
|
+
* {messageID, path} (session media §3 4b): the local HTML page the message names and its files, through the spoke's
|
|
2290
|
+
* previewBundle op (createPagePreview).
|
|
2291
|
+
*/
|
|
2292
|
+
async function createPreview(userID: string, sessionID: string, request: Request): Promise<Response> {
|
|
2293
|
+
const input = await body(request)
|
|
2294
|
+
const extra = Object.keys(input).find((k) => k !== "messageID" && k !== "block" && k !== "path")
|
|
2295
|
+
if (extra !== undefined) return error(400, `Unknown field ${JSON.stringify(extra.slice(0, 50))}`)
|
|
2296
|
+
const { messageID, block, path } = input
|
|
2297
|
+
if (typeof messageID !== "string" || !/^[A-Za-z0-9_.:-]{1,200}$/.test(messageID)) return error(400, "messageID must be a message ID")
|
|
2298
|
+
if (path !== undefined) {
|
|
2299
|
+
if (block !== undefined) return error(400, "Send either block or path, not both")
|
|
2300
|
+
if (!isPreviewPath(path)) return error(400, `path must be 1–${PREVIEW_BUNDLE_LIMITS.pathMax} characters on one line`)
|
|
2301
|
+
return createPagePreview(userID, sessionID, messageID, path)
|
|
2302
|
+
}
|
|
2303
|
+
if (typeof block !== "number" || !Number.isSafeInteger(block) || block < 0 || block > 9999) return error(400, "block must be a whole number 0–9999")
|
|
2304
|
+
if (!artifacts) return error(422, "This hub does not store artifacts", "unsupported")
|
|
2305
|
+
const connID = ownerOf(sessionID)
|
|
2306
|
+
const texts = await messageTexts((page) => spokes.request(connID, { op: "timeline", sessionID, order: "desc", ...page }), messageID)
|
|
2307
|
+
if ("error" in texts) {
|
|
2308
|
+
return texts.error === "too_old" ? error(404, "This message is too old to preview", "too_old") : error(404, "No such message", "not_found")
|
|
2309
|
+
}
|
|
2310
|
+
const picked = pickHtmlBlock(texts.texts, block)
|
|
2311
|
+
if ("error" in picked) {
|
|
2312
|
+
return picked.error === "not_found" ? error(404, "No HTML block at that index", "not_found") : error(413, `The HTML block is larger than ${PREVIEW_LIMITS.htmlBytes} bytes`, "too_large")
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
const saved = await artifacts.savePreview(userID, { sessionID, messageID, block }, picked.html)
|
|
2316
|
+
const host = `/api/agent/artifacts/${saved.artifact.id}/revisions/${saved.revision.id}/host`
|
|
2317
|
+
return Response.json({ ...saved, host }, { status: saved.reused ? 200 : 201, headers: NO_STORE })
|
|
2318
|
+
} catch (cause) {
|
|
2319
|
+
return decisionFailure(cause)
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/**
|
|
2324
|
+
* POST …/previews {messageID, path}: the page `path` names (as written in the message) and the files it refers to,
|
|
2325
|
+
* read by the owning spoke under its media path policy (op previewBundle, feature "previewBundle"; the hub reads the
|
|
2326
|
+
* files the manifest does not inline chunk by chunk and checks each against it), stored as a preview artifact
|
|
2327
|
+
* revision (savePreview: unchanged files reuse it, a changed file is a new revision). Answers as for a block plus
|
|
2328
|
+
* `skipped` (files left out, with reasons) and `skippedMore`. 404 refused (not a page of this message under the
|
|
2329
|
+
* policy) or not_found (no such message, or hidden by a revert), 409 spoke offline or the page kept changing
|
|
2330
|
+
* (changed), 413 the page is over PREVIEW_BUNDLE_LIMITS.fileBytes ({bytes, max}), 422 a spoke without the feature
|
|
2331
|
+
* ({feature, harness}) or a hub without artifacts, 502 a malformed bundle.
|
|
2332
|
+
*/
|
|
2333
|
+
async function createPagePreview(userID: string, sessionID: string, messageID: string, path: string): Promise<Response> {
|
|
2334
|
+
if (!artifacts) return error(422, "This hub does not store artifacts", "unsupported")
|
|
2335
|
+
const connID = ownerOf(sessionID)
|
|
2336
|
+
if (!registry.supports(connID, "previewBundle")) {
|
|
2337
|
+
const lacking = { feature: "previewBundle", harness: registry.harnessOf(connID) ?? "unknown" }
|
|
2338
|
+
return Response.json({ error: unsupportedMessage(lacking), code: "unsupported", ...lacking }, { status: 422, headers: NO_STORE })
|
|
2339
|
+
}
|
|
2340
|
+
let fetched
|
|
2341
|
+
try {
|
|
2342
|
+
fetched = await fetchPreviewBundle((chunk) => spokes.request(connID, { op: "previewBundle", sessionID, messageID, path, ...chunk }))
|
|
2343
|
+
} catch (cause) {
|
|
2344
|
+
if (cause instanceof BundleProtocolError) return error(502, cause.message, "bad_bundle")
|
|
2345
|
+
throw cause
|
|
2346
|
+
}
|
|
2347
|
+
if ("error" in fetched) {
|
|
2348
|
+
switch (fetched.error) {
|
|
2349
|
+
case "refused":
|
|
2350
|
+
return error(404, "This page isn't one of this session's files", "refused")
|
|
2351
|
+
case "too_large":
|
|
2352
|
+
return Response.json({ error: `The page is larger than ${fetched.max} bytes`, code: "too_large", bytes: fetched.bytes, max: fetched.max }, { status: 413, headers: NO_STORE })
|
|
2353
|
+
case "changed":
|
|
2354
|
+
return error(409, "The page kept changing while it was read; try again", "changed")
|
|
2355
|
+
default:
|
|
2356
|
+
return error(404, "No such message", "not_found")
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
const saved = await artifacts.savePreview(userID, { sessionID, messageID, path }, fetched.bundle)
|
|
2361
|
+
const host = `/api/agent/artifacts/${saved.artifact.id}/revisions/${saved.revision.id}/host`
|
|
2362
|
+
const skipped = { skipped: fetched.skipped, ...(fetched.skippedMore ? { skippedMore: fetched.skippedMore } : {}) }
|
|
2363
|
+
return Response.json({ ...saved, host, ...skipped }, { status: saved.reused ? 200 : 201, headers: NO_STORE })
|
|
2364
|
+
} catch (cause) {
|
|
2365
|
+
return decisionFailure(cause)
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. On a spoke with
|
|
2371
|
+
* "timelineMedia" it is media/f<index> (chunked, verified, cached); else the older single-answer timelineFile op.
|
|
2372
|
+
*/
|
|
2373
|
+
async function timelineFile(sessionID: string, messageID: string, rawIndex: string, caller: MediaCaller): Promise<Response> {
|
|
2184
2374
|
const index = /^\d{1,3}$/.test(rawIndex) ? Number(rawIndex) : NaN
|
|
2185
2375
|
if (!Number.isInteger(index) || messageID.length > 300) return error(400, "Expected an attachment index 0–999")
|
|
2186
|
-
const
|
|
2376
|
+
const connID = ownerOf(sessionID)
|
|
2377
|
+
if (registry.supports(connID, "timelineMedia")) return timelineMediaRoute(caller, sessionID, messageID, `f${index}`)
|
|
2378
|
+
const { file, tooLarge } = await spokes.request(connID, { op: "timelineFile", sessionID, messageID, index })
|
|
2187
2379
|
if (tooLarge) return error(413, "This image is too large to show here")
|
|
2188
2380
|
// Only image types that are safe to render inline are served (never SVG or HTML).
|
|
2189
2381
|
if (!file || typeof file.data !== "string" || !PROMPT_IMAGE_MIMES.includes(file.mime)) return error(404, "No such image")
|
|
@@ -2193,6 +2385,33 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2193
2385
|
})
|
|
2194
2386
|
}
|
|
2195
2387
|
|
|
2388
|
+
/**
|
|
2389
|
+
* GET …/timeline/messages/:message/media/:ref (S4 M3): 400 for a malformed ref or message ID, 409 offline, 422
|
|
2390
|
+
* unsupported {feature, harness} when the owning spoke did not announce timelineMedia; else ./media-timeline.ts.
|
|
2391
|
+
*/
|
|
2392
|
+
async function timelineMediaRoute(caller: MediaCaller, sessionID: string, messageID: string, ref: string): Promise<Response> {
|
|
2393
|
+
const parsed = parseMediaRef(ref)
|
|
2394
|
+
if (!parsed) return error(400, "Expected a media ref (f<n>, t<part>.<item> or p:<path>)")
|
|
2395
|
+
if (!MEDIA_MESSAGE_ID.test(messageID)) return error(400, "Expected a message ID")
|
|
2396
|
+
const connID = ownerOf(sessionID)
|
|
2397
|
+
if (!registry.supports(connID, "timelineMedia")) {
|
|
2398
|
+
const lacking = { feature: "timelineMedia", harness: registry.harnessOf(connID) ?? "unknown" }
|
|
2399
|
+
return Response.json({ error: unsupportedMessage(lacking), code: "unsupported", ...lacking }, { status: 422, headers: NO_STORE })
|
|
2400
|
+
}
|
|
2401
|
+
return timelineMedia.serve({
|
|
2402
|
+
userID: caller.userID,
|
|
2403
|
+
connID,
|
|
2404
|
+
sessionID,
|
|
2405
|
+
messageID,
|
|
2406
|
+
ref,
|
|
2407
|
+
parsed,
|
|
2408
|
+
ifNoneMatch: caller.request.headers.get("if-none-match"),
|
|
2409
|
+
download: caller.url.searchParams.get("download") === "1",
|
|
2410
|
+
bypassCache: registry.revertStaged(sessionID),
|
|
2411
|
+
revertAt: registry.revertCommits(connID).find((c) => c.sessionID === sessionID)?.committed.at,
|
|
2412
|
+
})
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2196
2415
|
/**
|
|
2197
2416
|
* Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
|
|
2198
2417
|
* the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
|
|
@@ -2300,9 +2519,21 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2300
2519
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
2301
2520
|
if (!text) return error(400, "Empty utterance")
|
|
2302
2521
|
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
2303
|
-
|
|
2522
|
+
// (voice front P1) While a front is active, utterances go to the front; it relays them to the session.
|
|
2523
|
+
const front = registry.frontRoute(sessionID)
|
|
2304
2524
|
await humanTurn(sessionID)
|
|
2305
|
-
|
|
2525
|
+
// A front that cannot take it failed: the session talks directly (front_failed), never a 502.
|
|
2526
|
+
let inboxID = front
|
|
2527
|
+
? await spokes.request(front.connID, { op: "steer", sessionID: front.sessionID, text }).then(
|
|
2528
|
+
(result) => result.inboxID,
|
|
2529
|
+
(cause) => {
|
|
2530
|
+
console.warn(`[hub] voice front of ${sessionID} did not take the utterance: ${(cause as Error).message}`)
|
|
2531
|
+
registry.frontLost(sessionID)
|
|
2532
|
+
return undefined
|
|
2533
|
+
},
|
|
2534
|
+
)
|
|
2535
|
+
: undefined
|
|
2536
|
+
if (inboxID === undefined) inboxID = (await spokes.request(ownerOf(sessionID, "steer"), { op: "steer", sessionID, text })).inboxID
|
|
2306
2537
|
recordingTap(sessionID, "utterance.received", { inboxID, text })
|
|
2307
2538
|
registry.trackUtterance(sessionID, inboxID)
|
|
2308
2539
|
return Response.json({ inboxID }, { status: 202 })
|
|
@@ -2357,18 +2588,32 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2357
2588
|
}
|
|
2358
2589
|
|
|
2359
2590
|
async function conversation(request: Request, sessionID: string): Promise<Response> {
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2591
|
+
const input = await body(request)
|
|
2592
|
+
const on = input.on
|
|
2593
|
+
if (typeof on !== "boolean" || (input.front !== undefined && typeof input.front !== "boolean")) return error(400, "Expected {on: boolean, front?: boolean}")
|
|
2594
|
+
// (voice front P1) `front`: put a hidden voice front in front of the session, when its spoke serves it.
|
|
2595
|
+
const wantsFront = on && input.front === true
|
|
2596
|
+
const hadFront = registry.wantsFront(sessionID)
|
|
2362
2597
|
// Turning off always clears the hub's state, so a later reconnect does not switch it back on.
|
|
2363
2598
|
if (!on) registry.setConversation(sessionID, false)
|
|
2364
2599
|
// (REC-01) Off ends the session's recordings (with a grace for the console's last batches); best effort.
|
|
2365
2600
|
const tenant = registry.sessionTenant(sessionID)
|
|
2366
2601
|
if (!on && tenant !== undefined) await recordingService.conversationChanged(tenant, sessionID, false).catch((cause) => console.error(`[hub] recording end failed: ${(cause as Error).message}`))
|
|
2367
2602
|
const connID = ownerOf(sessionID)
|
|
2368
|
-
const
|
|
2369
|
-
|
|
2370
|
-
if (
|
|
2371
|
-
|
|
2603
|
+
const sendFront = wantsFront && registry.supports(connID, "voiceFront")
|
|
2604
|
+
// (voice front P1) Until the op answers, a "Voice: " session the spoke newly reports is not listed (the front).
|
|
2605
|
+
if (sendFront) registry.frontOpStarted(connID)
|
|
2606
|
+
let result: ResultFor["conversation"]
|
|
2607
|
+
try {
|
|
2608
|
+
result = await spokes.request(connID, { op: "conversation", sessionID, on, ...(sendFront ? { front: true } : {}) })
|
|
2609
|
+
const frontID = sendFront && typeof result.front?.sessionID === "string" ? result.front.sessionID : null
|
|
2610
|
+
if (on) registry.setConversation(sessionID, true, connID, wantsFront ? frontID : undefined)
|
|
2611
|
+
} finally {
|
|
2612
|
+
if (sendFront) registry.frontOpEnded(connID)
|
|
2613
|
+
}
|
|
2614
|
+
const front = registry.frontOf(sessionID) !== undefined
|
|
2615
|
+
if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
|
|
2616
|
+
return Response.json({ on, agent: result.agent, ...(wantsFront || (!on && hadFront) ? { front } : {}) })
|
|
2372
2617
|
}
|
|
2373
2618
|
|
|
2374
2619
|
/** Enables or disables voice for a session, through a process that reported it (service first). */
|
|
@@ -2819,6 +3064,16 @@ function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | un
|
|
|
2819
3064
|
return Object.keys(prefs).length ? prefs : undefined
|
|
2820
3065
|
}
|
|
2821
3066
|
|
|
3067
|
+
/** PUT /api/settings/media body: {allowlist: hostnames or `*.suffix`es}, normalised (packages/protocol media-allowlist). */
|
|
3068
|
+
function mediaSettingsInput(value: unknown): string[] {
|
|
3069
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new HttpError(400, "Expected {allowlist: [hostnames]}")
|
|
3070
|
+
const record = value as Record<string, unknown>
|
|
3071
|
+
if (Object.keys(record).some((key) => key !== "allowlist")) throw new HttpError(400, "Expected {allowlist: [hostnames]}")
|
|
3072
|
+
const parsed = parseMediaAllowlist(record.allowlist)
|
|
3073
|
+
if (!parsed.ok) throw new HttpError(400, parsed.reason)
|
|
3074
|
+
return parsed.allowlist
|
|
3075
|
+
}
|
|
3076
|
+
|
|
2822
3077
|
/** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
|
|
2823
3078
|
function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
2824
3079
|
const invalid = () =>
|