@huynle/hub 0.10.1 → 0.11.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 +80 -0
- package/README.md +36 -8
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/sessions.ts +4 -1
- package/packages/cli/dist/hub-cli.js +851 -466
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +12 -7
- package/packages/cli/src/decisions.ts +52 -4
- package/packages/cli/src/format.ts +9 -0
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +26 -4
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +9 -3
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +14 -3
- 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/api/v1/_root.json +119 -0
- package/packages/protocol/schema/api/v1/account.json +419 -0
- package/packages/protocol/schema/api/v1/decisions.json +635 -0
- package/packages/protocol/schema/api/v1/discovery.json +110 -0
- package/packages/protocol/schema/api/v1/events.json +679 -0
- package/packages/protocol/schema/api/v1/session-actions.json +660 -0
- package/packages/protocol/schema/api/v1/sessions.json +2055 -0
- package/packages/protocol/schema/api/v1/speech.json +492 -0
- package/packages/protocol/schema/api/v1/unspecified.json +282 -0
- package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
- package/packages/protocol/schema/app-front/v1.schema.json +7 -3
- package/packages/protocol/src/api-v1.ts +8 -0
- package/packages/protocol/src/index.ts +2 -0
- package/packages/protocol/src/teams.ts +16 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -1
- 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/app-front.ts +2 -2
- package/packages/sdk/src/ask-fields.ts +76 -0
- package/packages/sdk/src/client.ts +24 -2
- package/packages/sdk/src/envelope.ts +3 -2
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/team-intake.ts +6 -0
- package/packages/sdk/src/teams.ts +4 -0
- package/packages/sdk/src/types.ts +36 -4
- package/packages/sdk/src/wake.ts +9 -4
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/agent-limits.ts +185 -0
- package/src/central/agent/app-decisions.ts +6 -5
- package/src/central/agent/decisions.ts +136 -9
- package/src/central/agent/delivery.ts +164 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +9 -6
- package/src/central/agent/message-limits.ts +6 -0
- package/src/central/agent/message-routes.ts +33 -6
- package/src/central/agent/messages.ts +7 -0
- package/src/central/agent/placement.ts +4 -2
- package/src/central/agent/read-routes.ts +5 -0
- package/src/central/agent/routes.ts +13 -0
- package/src/central/agent/subscriptions.ts +15 -4
- package/src/central/api-v1.ts +115 -0
- package/src/central/artifacts/store.ts +4 -2
- package/src/central/concierge/board-results.ts +2 -1
- package/src/central/concierge/notifier.ts +4 -2
- package/src/central/config.ts +24 -3
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +380 -36
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +236 -17
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +68 -3
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/registry.ts +34 -3
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +80 -0
- package/src/central/teams/intake.ts +38 -6
- package/src/central/teams/involvement.ts +95 -0
- package/src/central/teams/item-decisions.ts +60 -0
- package/src/central/teams/item-eligibility.ts +35 -0
- package/src/central/teams/item-stalls.ts +87 -0
- package/src/central/teams/loops.ts +40 -0
- package/src/central/teams/policy.ts +10 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +20 -1
- package/src/central/teams/validation.ts +16 -2
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
- package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
- package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-B2IfKm37.js +5 -0
- package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
- package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
- package/web/dist/assets/index-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.js +0 -5
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 { CONNECT_PATHS, formAnswerProblem, isDeliverResult, 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, type SessionModelPrefs } from "../protocol"
|
|
11
|
+
import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, RECORDING_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, type SessionModelPrefs, type SessionSummary } 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"
|
|
@@ -36,14 +36,16 @@ import { createResolver, type AgentActor, type BearerVerifier, type CredentialMo
|
|
|
36
36
|
import { ActingSwitch } from "./agent/acting-switch"
|
|
37
37
|
import { AgentAudit } from "./agent/audit"
|
|
38
38
|
import { Grants } from "./agent/grants"
|
|
39
|
-
import { ANSWER_VIAS, type AnswerVia, Decisions, type Decision, TAP_ONLY } from "./agent/decisions"
|
|
39
|
+
import { ANSWER_VIAS, answerDetail, answerPicks, type AnswerVia, Decisions, type Decision, TAP_ONLY } from "./agent/decisions"
|
|
40
40
|
import { Acting, type Escalate } from "./agent/acting"
|
|
41
41
|
import { Escalations } from "./agent/escalation"
|
|
42
42
|
import { GrantRequests } from "./agent/grant-requests"
|
|
43
43
|
import { ConciergeGrantRequests, conciergeGrantRequestRoutes, watchedProjects } from "./concierge/grant-request"
|
|
44
44
|
import { partyFromActor } from "./agent/envelope"
|
|
45
45
|
import { Delivery } from "./agent/delivery"
|
|
46
|
+
import { AgentLimits } from "./agent/agent-limits"
|
|
46
47
|
import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "./agent/errors"
|
|
48
|
+
import { apiV1Response, unversionedURL } from "./api-v1"
|
|
47
49
|
import type { Launch } from "./agent/launch-store"
|
|
48
50
|
import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
|
|
49
51
|
import { hubDirectory } from "./agent/directory-routes"
|
|
@@ -60,7 +62,7 @@ import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./
|
|
|
60
62
|
import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
|
|
61
63
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
62
64
|
import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
|
|
63
|
-
import { audioFormat, SpeechError } from "./speech/provider"
|
|
65
|
+
import { audioFormat, SpeechError, type TranscribeStats } from "./speech/provider"
|
|
64
66
|
import { SpeechService } from "./speech/service"
|
|
65
67
|
import { NodeRegistry } from "./nodes"
|
|
66
68
|
import { ConnDirectory } from "./cluster/directory"
|
|
@@ -85,7 +87,7 @@ import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinato
|
|
|
85
87
|
import { artifactJournalEvent } from "./artifacts/events"
|
|
86
88
|
import { ArtifactMaintenance } from "./artifacts/maintenance"
|
|
87
89
|
import { MemoryBlobStore } from "./backend/memory-blobs"
|
|
88
|
-
import { RecordingService } from "./recording"
|
|
90
|
+
import { RecordingService, type DevLimits } from "./recording"
|
|
89
91
|
import { recordingRoutes } from "./recording/routes"
|
|
90
92
|
import { wrapConciergeSend } from "./recording/concierge-taps"
|
|
91
93
|
import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
|
|
@@ -100,6 +102,16 @@ import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
|
|
|
100
102
|
import { TeamSeatStore } from "./teams/seats"
|
|
101
103
|
import { TeamStaffingService } from "./teams/staffing"
|
|
102
104
|
import { intakeActor, TeamIntakeService } from "./teams/intake"
|
|
105
|
+
import { TeamHopStalls } from "./teams/hop-stalls"
|
|
106
|
+
import { itemEligibility } from "./teams/item-eligibility"
|
|
107
|
+
import { ItemBudgets } from "./teams/budgets"
|
|
108
|
+
import { TeamInvolvement } from "./teams/involvement"
|
|
109
|
+
/** (TA2-8) The hub itself, for a reset it made on observed progress (no user, device or session acted). */
|
|
110
|
+
const HUB_PROGRESS_PARTY = { kind: "app", tokenID: "team-progress", label: "Hub progress" } as const
|
|
111
|
+
import { LoopDetector, type LoopThreshold } from "./teams/loops"
|
|
112
|
+
import { ItemStalls } from "./teams/item-stalls"
|
|
113
|
+
import { ItemDecisions, itemDecisionQuestion } from "./teams/item-decisions"
|
|
114
|
+
import { DiffSampler, ProgressSignals, type ProgressSignal } from "./teams/progress"
|
|
103
115
|
import { TeamStore } from "./teams/store"
|
|
104
116
|
import { ActivityTracker } from "./concierge/activity"
|
|
105
117
|
import { HumanActivity, withHumanActivity } from "./human-activity"
|
|
@@ -109,7 +121,7 @@ import { conciergeBoard } from "./concierge/board-routes"
|
|
|
109
121
|
import { renderFrontBoard } from "./concierge/board-render"
|
|
110
122
|
import { ContextPusher } from "./concierge/context"
|
|
111
123
|
import { C1_ENTITLEMENTS } from "./concierge/entitlements"
|
|
112
|
-
import { ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
|
|
124
|
+
import { cardTriggerOf, ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
|
|
113
125
|
import { TRANSCRIPT_ROWS } from "./context-cards/inputs"
|
|
114
126
|
import { CONTEXT_CARD_TRIGGERS } from "./../protocol"
|
|
115
127
|
import type { ChatClient } from "./llm/chat"
|
|
@@ -137,7 +149,8 @@ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEY
|
|
|
137
149
|
const HUMAN_ACTIVITY_STOP_MS = 3_000
|
|
138
150
|
|
|
139
151
|
export interface Speech {
|
|
140
|
-
|
|
152
|
+
/** `stats` (recording mode): the provider fills in its upstream request's status, bytes and time to headers. */
|
|
153
|
+
transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
|
|
141
154
|
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
142
155
|
/** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
|
|
143
156
|
speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
|
|
@@ -149,6 +162,8 @@ export interface CentralOptions {
|
|
|
149
162
|
readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
|
|
150
163
|
/** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
|
|
151
164
|
readonly registryTimers?: Timers
|
|
165
|
+
/** Developer mode's recording limits (tests shrink them; default DEV_LIMITS). */
|
|
166
|
+
readonly recordingDevLimits?: Partial<DevLimits>
|
|
152
167
|
/** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
|
|
153
168
|
readonly webDir?: string
|
|
154
169
|
readonly web?: WebAssets
|
|
@@ -200,6 +215,10 @@ export interface CentralOptions {
|
|
|
200
215
|
readonly leaderTtlMs?: number
|
|
201
216
|
/** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
|
|
202
217
|
readonly loopTickMs?: number
|
|
218
|
+
/** Test-only initial agent-limits retry scheduler; production starts at 1s, doubling to 30s. */
|
|
219
|
+
readonly agentLimitsRetry?: (ms: number) => Promise<void>
|
|
220
|
+
/** Bound requests held for initial agent-limits hydration (default 5s; tests shorten it). */
|
|
221
|
+
readonly agentLimitsInitialWaitMs?: number
|
|
203
222
|
/** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
|
|
204
223
|
readonly drainTimeoutMs?: number
|
|
205
224
|
readonly drainRetryAfterMs?: number
|
|
@@ -222,6 +241,7 @@ export interface Central {
|
|
|
222
241
|
readonly speechSettings: SpeechService
|
|
223
242
|
/** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
|
|
224
243
|
readonly backends: Backends
|
|
244
|
+
readonly agentLimits: AgentLimits
|
|
225
245
|
/** Agent messages: store and delivery to sessions (the message routes use it). */
|
|
226
246
|
readonly delivery: Delivery
|
|
227
247
|
/** Decisions (ask @me): answered by the user on /api/decisions. */
|
|
@@ -466,6 +486,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
466
486
|
/** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
|
|
467
487
|
const ownBackends = !options.backends
|
|
468
488
|
const backends = options.backends ?? hubBackends(config, { now })
|
|
489
|
+
const agentLimits = new AgentLimits(backends.state, { hopLimit: config.hopLimit, hopCeiling: config.hopCeiling, ...(config.launchCaps ? { launchCaps: config.launchCaps } : {}) }, { hopLimit: config.agentLimitsSource === "hub.json", hopCeiling: config.hopCeilingSource === "hub.json", launchCaps: config.agentLimitsSource === "hub.json" }, backends.bus, { now, ...(options.timing?.agentLimitsRetry ? { retry: options.timing.agentLimitsRetry } : {}) })
|
|
490
|
+
// Observe startup failure immediately (no unhandled rejection), but never serve on an unknown cap.
|
|
491
|
+
const agentLimitsReady = agentLimits.start().catch((cause: unknown) => {
|
|
492
|
+
console.error("[hub] agent limits could not load; refusing requests:", cause)
|
|
493
|
+
})
|
|
469
494
|
// Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
|
|
470
495
|
// migration runs on start). Otherwise state.json in the state dir.
|
|
471
496
|
const store = config.cluster
|
|
@@ -888,7 +913,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
888
913
|
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
889
914
|
// Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
|
|
890
915
|
let teamSeats: TeamSeatStore | undefined
|
|
891
|
-
const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
916
|
+
const teamStore = new TeamStore({ state: backends.state, now, hopCeiling: () => agentLimits.snapshot().hopCeiling, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
892
917
|
const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
|
|
893
918
|
teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
|
|
894
919
|
const teamPolicy = new TeamPolicy({
|
|
@@ -901,8 +926,167 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
901
926
|
await Promise.all([audit.record(userID, { actor: { kind: "session", sessionID: denial.senderID }, verb: "team.denied", target, decision: "forbidden", reason, outcome: "refused" }), agentEvents.append(userID, teamDeniedEvent(denial))])
|
|
902
927
|
},
|
|
903
928
|
})
|
|
904
|
-
|
|
929
|
+
let decisions: Decisions
|
|
930
|
+
const teamHopStalls = new TeamHopStalls({
|
|
931
|
+
state: backends.state,
|
|
932
|
+
now,
|
|
933
|
+
close: async (userID, decisionID, reason) => { await decisions.close(userID, decisionID, reason ?? "A person continued this team's work; renewal is no longer needed") },
|
|
934
|
+
notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
|
|
935
|
+
open: async (userID, teamID, sender, recipient, limit) => {
|
|
936
|
+
const team = await teamStore.require(userID, teamID)
|
|
937
|
+
const intake = await teamIntake?.list(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, teamID, { limit: 50 })
|
|
938
|
+
const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
|
|
939
|
+
const detail = active ? `, intake ${active.id} (${active.title})` : ""
|
|
940
|
+
const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-hop", scopes: [], label: "Hub team hop cap" }, {
|
|
941
|
+
question: `Team #${team.name}${detail} reached its effective ${limit}-hop limit (${team.hopLimit !== undefined && team.hopLimit < agentLimits.snapshot().hopLimit ? "team" : `hub-wide ${agentLimits.snapshot().sources.hopLimit}`}; ${sender} → ${recipient}). Renew this team's seats?`,
|
|
942
|
+
options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
|
|
943
|
+
urgency: "high", tier: "tap", about: [recipient],
|
|
944
|
+
}, { action: { kind: "teamHopRenew", teamID } })
|
|
945
|
+
return decision.id
|
|
946
|
+
},
|
|
947
|
+
})
|
|
948
|
+
const teamInvolvement = new TeamInvolvement({
|
|
949
|
+
seats: async (userID, teamID) => [...new Set((await teamSeats!.list(userID, teamID, { current: true })).map((seat) => seat.sessionID))],
|
|
950
|
+
seatsOf: (userID, sessionID) => teamSeats!.seatsOf(userID, sessionID),
|
|
951
|
+
reset: (userID, sessionID) => delivery.hops.humanTurn(userID, sessionID),
|
|
952
|
+
stalls: teamHopStalls,
|
|
953
|
+
audit: async ({ userID, teamID, kind, ref, seats, failed, actor }) => { await audit.record(userID, { actor: kind === "progress" ? HUB_PROGRESS_PARTY : { kind: "user" }, ...(actor?.deviceID ? { deviceID: actor.deviceID } : {}), verb: "team.hops.reset", target: {}, decision: "allow", outcome: "ok", reason: `${teamID} ${kind}:${ref}, ${seats.length} seats, ${failed.length} failed${actor?.via ? `, via ${actor.via}` : ""}` }) },
|
|
954
|
+
event: async ({ userID, teamID, kind, ref, seats, failed }) => { await agentEvents.append(userID, { type: "team.hops.reset", family: "manage", subject: { teamID }, data: { kind, ref, seats: seats.length, failed: failed.length } }) },
|
|
955
|
+
})
|
|
956
|
+
const itemBudgets = new ItemBudgets({
|
|
957
|
+
state: backends.state, now,
|
|
958
|
+
limits: async (userID, teamID) => (await teamStore.get(userID, teamID))?.itemBudget ?? agentLimits.snapshot().itemBudget!,
|
|
959
|
+
exhausted: async (userID, teamID, itemID, reason, limit, usage) => {
|
|
960
|
+
await agentEvents.append(userID, { type: "team.item_budget.exhausted", family: "manage", subject: { teamID, ...(itemID ? { intakeID: itemID } : {}) }, data: { scope: itemID ? "item" : "pool", reason, limit, usageWakes: usage.wakes, usageHours: Math.round(usage.hours * 10) / 10 } })
|
|
961
|
+
// (TA2-7) An item opens its one Extend/Stop Decision; the unattributed pool only gets a quiet entry-seat notice.
|
|
962
|
+
if (itemID) await itemStalls.hit(userID, teamID, itemID, "budget")
|
|
963
|
+
else await delivery.notifyTeamEntry(userID, teamID, `This team's unattributed wake pool is spent (${limit} wakes per 24 h). Tag messages with their intake item, or wait; the pool opens no Decision.`)
|
|
964
|
+
},
|
|
965
|
+
})
|
|
966
|
+
/** (TA2-7) The effective item-loop threshold: the published team's override, else the hub-wide setting. */
|
|
967
|
+
const loopThreshold = async (userID: string, teamID: string): Promise<LoopThreshold> => (await teamStore.get(userID, teamID))?.loop ?? agentLimits.snapshot().loop!
|
|
968
|
+
const loopWindowMs = async (userID: string, teamID: string) => (await loopThreshold(userID, teamID)).windowMinutes * 60_000
|
|
969
|
+
const itemLoops = new LoopDetector({ thresholds: () => agentLimits.snapshot().loop!, now })
|
|
970
|
+
const hubReader = (userID: string) => ({ kind: "user" as const, userID, deviceID: "hub", via: "cookie" as const })
|
|
971
|
+
const itemStalls: ItemStalls = new ItemStalls({
|
|
972
|
+
state: backends.state,
|
|
973
|
+
now,
|
|
974
|
+
open: async (userID, teamID, item, reason, pair) => {
|
|
975
|
+
const team = await teamStore.require(userID, teamID)
|
|
976
|
+
const intake = await teamIntake!.get(userID, hubReader(userID), teamID, item)
|
|
977
|
+
const usage = await itemBudgets.usage(userID, teamID, item, intake.startedAt)
|
|
978
|
+
const question = itemDecisionQuestion({ userID, teamID, item, title: intake.title, reason, decisionID: "", ...(pair ? { pair } : {}), usage: { wakes: usage.wakes, hours: usage.hours }, limit: usage.budget })!
|
|
979
|
+
const about = pair ? [...pair] : intake.sessionID ? [intake.sessionID] : undefined
|
|
980
|
+
const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-item", scopes: [], label: "Hub team item budget" }, {
|
|
981
|
+
question: `Team #${team.name}: ${question}`,
|
|
982
|
+
options: [{ id: "extend", label: "Extend" }, { id: "stop", label: "Stop", safe: true }],
|
|
983
|
+
urgency: "high", tier: "tap", ...(about ? { about } : {}),
|
|
984
|
+
}, { action: { kind: "teamItem", teamID, item, reason, ...(pair ? { pair } : {}) } })
|
|
985
|
+
return decision.id
|
|
986
|
+
},
|
|
987
|
+
notice: async (userID, teamID, item, decisionID) => {
|
|
988
|
+
await delivery.notifyTeamEntry(userID, teamID, `Item ${item} is paused for the user (Decision ${decisionID}: Extend or Stop). Its messages still arrive without waking anyone; other items keep flowing.`)
|
|
989
|
+
},
|
|
990
|
+
})
|
|
991
|
+
/**
|
|
992
|
+
* (TA2-7) An item's Extend/Stop Decision ended unanswered. Closed by the hub: clear its stall. Expired: if the item is
|
|
993
|
+
* still open and still paused (budget still spent, or the pair still stopped), re-open ONE fresh Decision, once; after
|
|
994
|
+
* that the item stays paused with its stall recorded until the user acts. Otherwise the stall is cleared.
|
|
995
|
+
*/
|
|
996
|
+
const itemDecisionEnded = async (userID: string, decision: Decision) => {
|
|
997
|
+
const action = decision.action
|
|
998
|
+
if (action?.kind !== "teamItem" || (decision.state !== "expired" && decision.state !== "closed")) return
|
|
999
|
+
const { teamID, item, reason, pair } = action
|
|
1000
|
+
// Lifting a stall also forgets the stopped pair's window, or its next message would stop it again at once.
|
|
1001
|
+
const lift = async () => {
|
|
1002
|
+
if (await itemStalls.clearFor(userID, teamID, item, decision.id) && pair) itemLoops.clear({ userID, teamID, item, from: pair[0], to: pair[1] })
|
|
1003
|
+
}
|
|
1004
|
+
if (decision.state === "closed") { await lift(); return }
|
|
1005
|
+
const intake = await teamIntake!.get(userID, hubReader(userID), teamID, item).catch(() => undefined)
|
|
1006
|
+
const open = intake !== undefined && (intake.status === "queued" || intake.status === "routed")
|
|
1007
|
+
let paused = open && reason === "loop"
|
|
1008
|
+
if (open && reason === "budget") {
|
|
1009
|
+
const usage = await itemBudgets.usage(userID, teamID, item, intake.startedAt)
|
|
1010
|
+
paused = usage.wakes >= usage.budget.wakes || usage.hours >= usage.budget.hours
|
|
1011
|
+
}
|
|
1012
|
+
if (!paused) { await lift(); return }
|
|
1013
|
+
await itemStalls.reopen(userID, teamID, item, decision.id)
|
|
1014
|
+
}
|
|
1015
|
+
/** (TA2-7) The Extend/Stop ports for one answer: Stop fails the intake as the user who tapped it (their device). */
|
|
1016
|
+
const itemDecisionsFor = (teamID: string, by: { readonly userID: string; readonly deviceID: string }) => new ItemDecisions({
|
|
1017
|
+
matches: async (userID, team, item, decisionID) => (await itemStalls.get(userID, team, item))?.decisionID === decisionID,
|
|
1018
|
+
clearStall: async (userID, item, decisionID) => {
|
|
1019
|
+
if ((await itemStalls.get(userID, teamID, item))?.decisionID === decisionID) await itemStalls.clear(userID, teamID, item)
|
|
1020
|
+
},
|
|
1021
|
+
extend: (userID, team, item, decisionID) => itemBudgets.extend(userID, team, item, decisionID),
|
|
1022
|
+
clearLoop: async (userID, item, pair) => { itemLoops.clear({ userID, teamID, item, from: pair[0], to: pair[1] }) },
|
|
1023
|
+
involved: (userID, act) => teamInvolvement.involved(userID, act),
|
|
1024
|
+
stop: async (userID, team, item, reason) => {
|
|
1025
|
+
const actor = { kind: "user" as const, userID, deviceID: by.deviceID, via: "cookie" as const }
|
|
1026
|
+
const current = await teamIntake!.get(userID, actor, team, item)
|
|
1027
|
+
if (current.status === "failed" || current.status === "cancelled") return
|
|
1028
|
+
// A still-queued item can only be cancelled (intake rule); a routed one fails. Either way, as the user, with the reason.
|
|
1029
|
+
await teamIntake!.setStatus(userID, actor, team, item, { status: current.status === "queued" ? "cancelled" : "failed", expectedRevision: current.revision, reason })
|
|
1030
|
+
},
|
|
1031
|
+
audit: async ({ userID, actor, deviceID, verb, reason }) => { await audit.record(userID, { actor, deviceID, verb, target: {}, decision: "allow", outcome: "ok", reason }) },
|
|
1032
|
+
})
|
|
1033
|
+
/**
|
|
1034
|
+
* (TA2-5 → TA2-7/8, decision 90utvfcb) Every qualifying progress signal restarts the item's loop windows, so real work
|
|
1035
|
+
* isn't flagged as a loop. Hop depth is different: the hub sees THAT something was written, not whether it was useful,
|
|
1036
|
+
* so only the seat's first move of an item into in_progress (once per item) resets the charged seats' depth; an
|
|
1037
|
+
* artifact, a diff or a console answer never does (a console act already resets the whole team, phase 1).
|
|
1038
|
+
* Cluster caveat: loop windows live in this pod's memory (best-effort across restarts and pods); the item budget is
|
|
1039
|
+
* stored under CAS and is the authoritative bound.
|
|
1040
|
+
*/
|
|
1041
|
+
const onProgress = async (signal: ProgressSignal) => {
|
|
1042
|
+
itemLoops.progress(signal)
|
|
1043
|
+
if (signal.kind !== "intake" || !signal.item) return
|
|
1044
|
+
try {
|
|
1045
|
+
const charged = await itemBudgets.charged(signal.userID, signal.teamID, signal.item, await loopWindowMs(signal.userID, signal.teamID))
|
|
1046
|
+
await teamInvolvement.progress(signal.userID, signal.teamID, signal.item, charged)
|
|
1047
|
+
} catch (cause) { console.error("[hub] item progress reset failed:", cause) }
|
|
1048
|
+
}
|
|
1049
|
+
const seatContext = async (userID: string, sessionID: string) => {
|
|
1050
|
+
const teams: { teamID: string; item?: string }[] = []
|
|
1051
|
+
for (const { teamID } of await teamSeats!.seatsOf(userID, sessionID)) {
|
|
1052
|
+
const item = await itemBudgets.current(userID, teamID, sessionID).catch(() => undefined)
|
|
1053
|
+
teams.push({ teamID, ...(item ? { item } : {}) })
|
|
1054
|
+
}
|
|
1055
|
+
return { teams }
|
|
1056
|
+
}
|
|
1057
|
+
/** (TA2-5) A seat's turn ended (the journal's working → idle|error, the context cards' definition): sample its diff. */
|
|
1058
|
+
const turnEndedDiff = async (userID: string, event: Parameters<typeof cardTriggerOf>[0]) => {
|
|
1059
|
+
const trigger = cardTriggerOf(event)
|
|
1060
|
+
if (trigger?.trigger === "turn-end") await diffSampler.turnEnded(userID, trigger.sessionID).catch((cause) => console.error("[hub] diff progress failed:", cause))
|
|
1061
|
+
}
|
|
1062
|
+
const progressSignals = new ProgressSignals({ seatContext, emit: onProgress, now, loopWindowMs: (userID, teamID) => loopWindowMs(userID, teamID) })
|
|
1063
|
+
const diffSampler = new DiffSampler({
|
|
1064
|
+
seats: async (userID, sessionID) => (await seatContext(userID, sessionID)).teams,
|
|
1065
|
+
summary: async (userID, sessionID) => (registry.sessionTenant(sessionID) === userID ? ((await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary) : undefined),
|
|
1066
|
+
emit: onProgress,
|
|
1067
|
+
now,
|
|
1068
|
+
loopWindowMs: (userID, teamID) => loopWindowMs(userID, teamID),
|
|
1069
|
+
})
|
|
1070
|
+
const itemChecks = itemEligibility({ seats: teamSeats!, teams: teamStore, intake: { get: (userID, actor, teamID, itemID) => teamIntake!.get(userID, actor, teamID, itemID) } as Pick<TeamIntakeService, "get"> })
|
|
1071
|
+
const delivery: Delivery = new Delivery({
|
|
1072
|
+
itemBudgets,
|
|
1073
|
+
items: itemChecks,
|
|
1074
|
+
// (TA2-7) Every item-tagged pair message counts against the effective threshold; a stop opens the item's Decision.
|
|
1075
|
+
itemLoop: async (userID, { teamID, item, from, to }) => {
|
|
1076
|
+
const stall = await itemStalls.get(userID, teamID, item).catch(() => undefined)
|
|
1077
|
+
if (stall?.reason === "loop" && stall.pair?.includes(from) && stall.pair.includes(to)) return true
|
|
1078
|
+
if (!itemLoops.message({ userID, teamID, item, from, to }, await loopThreshold(userID, teamID))) return false
|
|
1079
|
+
await itemStalls.hit(userID, teamID, item, "loop", [from, to].sort() as [string, string]).catch((cause) => console.error("[hub] item loop Decision failed:", cause))
|
|
1080
|
+
return true
|
|
1081
|
+
},
|
|
1082
|
+
hopLimit: config.hopLimit,
|
|
1083
|
+
currentHopLimit: () => agentLimits.snapshot().hopLimit,
|
|
905
1084
|
teams: teamPolicy,
|
|
1085
|
+
teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
|
|
1086
|
+
teamHopNotice: async (userID, teamID) => {
|
|
1087
|
+
const team = await teamStore.get(userID, teamID)
|
|
1088
|
+
return (await teamSeats!.list(userID, teamID, { current: true })).find((seat) => seat.role === team?.entryRole)?.sessionID
|
|
1089
|
+
},
|
|
906
1090
|
// CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
|
|
907
1091
|
inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
|
|
908
1092
|
state: backends.state,
|
|
@@ -937,7 +1121,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
937
1121
|
/** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
|
|
938
1122
|
let conciergeTierHooks: ConciergeTiers | undefined
|
|
939
1123
|
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
940
|
-
|
|
1124
|
+
decisions = new Decisions({
|
|
1125
|
+
answered: async (userID, decision, by) => {
|
|
1126
|
+
if (by.via === "cookie" && decision.from.kind === "session") await progressSignals.consoleAnswered(userID, { sessionID: decision.from.sessionID, via: by.via, ref: decision.id, kind: "decision" }).catch((cause) => console.error("[hub] decision progress failed:", cause))
|
|
1127
|
+
if (by.via === "cookie" && decision.action?.kind !== "teamHopRenew" && decision.action?.kind !== "teamItem" && decision.from.kind === "session")
|
|
1128
|
+
await teamInvolvement.involved(userID, { kind: "decision", sessionID: decision.from.sessionID, ref: decision.id, actor: { deviceID: by.deviceID, via: by.via } })
|
|
1129
|
+
},
|
|
941
1130
|
state: backends.state,
|
|
942
1131
|
delivery,
|
|
943
1132
|
now,
|
|
@@ -946,6 +1135,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
946
1135
|
// The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
|
|
947
1136
|
changed: (userID, decision) => {
|
|
948
1137
|
broadcast({ type: "decision", decision }, { tenant: userID })
|
|
1138
|
+
if (decision.action?.kind === "teamItem") void itemDecisionEnded(userID, decision).catch((cause) => console.error("[hub] item Decision end failed:", cause))
|
|
949
1139
|
conciergeTierHooks?.decisionChanged?.(userID, decision)
|
|
950
1140
|
// (REC-01) decision.opened / decision.answered of a recorded session (best effort).
|
|
951
1141
|
recordings?.decisionChanged(userID, decision)
|
|
@@ -954,6 +1144,21 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
954
1144
|
// (CON-06) Concierge proposals run their verb as the user (all set up below).
|
|
955
1145
|
act: (userID, decision, choice, by) => {
|
|
956
1146
|
const kind = decision.action?.kind
|
|
1147
|
+
if (kind === "teamHopRenew") {
|
|
1148
|
+
if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
|
|
1149
|
+
const teamID = decision.action!.teamID
|
|
1150
|
+
return choice.option === "renew"
|
|
1151
|
+
? teamInvolvement.renew(userID, teamID, decision.id, { deviceID: by.deviceID, via: by.via }).then(() => "done" as const)
|
|
1152
|
+
: Promise.resolve("done" as const)
|
|
1153
|
+
}
|
|
1154
|
+
if (kind === "teamItem") {
|
|
1155
|
+
const action = decision.action as Extract<NonNullable<typeof decision.action>, { kind: "teamItem" }>
|
|
1156
|
+
if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
|
|
1157
|
+
return (async () => {
|
|
1158
|
+
const usage = await itemBudgets.usage(userID, action.teamID, action.item).catch(() => undefined)
|
|
1159
|
+
return itemDecisionsFor(action.teamID, by).answer({ userID, teamID: action.teamID, item: action.item, title: "", reason: action.reason, decisionID: decision.id, ...(action.pair ? { pair: action.pair } : {}), usage: { wakes: usage?.wakes ?? 0, hours: usage?.hours ?? 0 }, limit: usage?.budget ?? { wakes: 0, hours: 0 } }, choice.option, by)
|
|
1160
|
+
})()
|
|
1161
|
+
}
|
|
957
1162
|
if (kind === "act") {
|
|
958
1163
|
if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
|
|
959
1164
|
return conciergeTierHooks.act(userID, decision, choice, by)
|
|
@@ -975,6 +1180,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
975
1180
|
const tenant = registry.sessionTenant(sessionID)
|
|
976
1181
|
if (tenant === undefined) return
|
|
977
1182
|
await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
|
|
1183
|
+
await teamHopStalls.clearForHuman(tenant, sessionID).catch((cause) => console.error(`[hub] team hop reset failed: ${(cause as Error).message}`))
|
|
978
1184
|
}
|
|
979
1185
|
registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
|
|
980
1186
|
// Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
|
|
@@ -986,6 +1192,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
986
1192
|
void revertWindows.observe(tenant, sessionID, committed).catch((cause) => console.error(`[hub] revert window not kept: ${(cause as Error).message}`))
|
|
987
1193
|
}
|
|
988
1194
|
})
|
|
1195
|
+
// Spoke-origin human-turn evidence retains its existing per-seat reset only; it is not console authentication.
|
|
989
1196
|
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
990
1197
|
// (Session findability, decision iaspcry7) "Touched by me" and the activity meter: per user, per session; the
|
|
991
1198
|
// owner's consoles get each change as {type: "activity", sessionID, activity}.
|
|
@@ -1062,6 +1269,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1062
1269
|
locks: backends.locks,
|
|
1063
1270
|
owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
|
|
1064
1271
|
...(config.launchCaps ? { caps: config.launchCaps } : {}),
|
|
1272
|
+
currentCaps: () => agentLimits.snapshot().launches,
|
|
1065
1273
|
nodes,
|
|
1066
1274
|
nodeOps: spokes,
|
|
1067
1275
|
registry,
|
|
@@ -1172,7 +1380,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1172
1380
|
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
1173
1381
|
kv: backends.kv,
|
|
1174
1382
|
now,
|
|
1175
|
-
onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), contextCards?.onEvent(userID, record.event), subscriptions?.observe(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
|
|
1383
|
+
onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), void turnEndedDiff(userID, record.event), contextCards?.onEvent(userID, record.event), subscriptions?.observe(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
|
|
1176
1384
|
})
|
|
1177
1385
|
const agentEventWriters = new EventWriters({
|
|
1178
1386
|
events: agentEvents,
|
|
@@ -1196,7 +1404,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1196
1404
|
workspaces: artifactWorkspacesFromState(backends.state),
|
|
1197
1405
|
...(options.previewRetention?.archiveAfterMs !== undefined ? { previewArchiveAfterMs: options.previewRetention.archiveAfterMs } : {}),
|
|
1198
1406
|
...(options.previewRetention?.purgeAfterMs !== undefined ? { previewPurgeAfterMs: options.previewRetention.purgeAfterMs } : {}),
|
|
1199
|
-
onEvent: (userID, event) =>
|
|
1407
|
+
onEvent: (userID, event) => {
|
|
1408
|
+
void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`))
|
|
1409
|
+
if ((event.type === "artifact.created" || event.type === "artifact.revised") && event.authorSessionID)
|
|
1410
|
+
void progressSignals.artifactChanged(userID, event.artifactID, event.revision, event.authorSessionID).catch((cause) => console.error("[hub] artifact progress failed:", cause))
|
|
1411
|
+
},
|
|
1200
1412
|
})
|
|
1201
1413
|
: undefined
|
|
1202
1414
|
const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
|
|
@@ -1313,6 +1525,23 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1313
1525
|
audit: (entry) => store.audit(entry),
|
|
1314
1526
|
users: allUsers,
|
|
1315
1527
|
log: (message) => console.error(message),
|
|
1528
|
+
pod: config.cluster?.podID ?? "hub",
|
|
1529
|
+
// (Developer mode) What ran a turn: the session's model and context size from its spoke (summary), counts only.
|
|
1530
|
+
turnFacts: async (_userID, sessionID) => {
|
|
1531
|
+
const summary = (await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary
|
|
1532
|
+
const model = summary?.model
|
|
1533
|
+
const context = summary?.context
|
|
1534
|
+
const agent = registry.session(sessionID)?.agent
|
|
1535
|
+
return {
|
|
1536
|
+
...(model?.providerID ? { provider: model.providerID } : {}),
|
|
1537
|
+
...(model?.modelID ? { model: model.modelID } : context?.model ? { model: context.model } : {}),
|
|
1538
|
+
...(model?.variant ? { variant: model.variant } : {}),
|
|
1539
|
+
...(agent ? { agent } : {}),
|
|
1540
|
+
...(typeof context?.used === "number" ? { contextTokens: context.used } : {}),
|
|
1541
|
+
...(typeof context?.limit === "number" ? { contextLimit: context.limit } : {}),
|
|
1542
|
+
}
|
|
1543
|
+
},
|
|
1544
|
+
...(options.recordingDevLimits ? { devLimits: options.recordingDevLimits } : {}),
|
|
1316
1545
|
})
|
|
1317
1546
|
recordings = recordingService
|
|
1318
1547
|
registry.recordingTap = recordingService.registryTap
|
|
@@ -1378,12 +1607,27 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1378
1607
|
const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
|
|
1379
1608
|
// Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
|
|
1380
1609
|
const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
|
|
1381
|
-
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) })
|
|
1610
|
+
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, hopStalls: teamHopStalls, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
|
|
1382
1611
|
registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
|
|
1383
1612
|
launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
|
|
1384
1613
|
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
|
|
1385
1614
|
// message inherits the item's notAfter (H4); queued items past it are cancelled by the team-intake leader's sweep.
|
|
1386
1615
|
teamIntake = new TeamIntakeService({
|
|
1616
|
+
budgets: itemBudgets,
|
|
1617
|
+
parentOf: async (userID, teamID, sessionID) => {
|
|
1618
|
+
const current = await itemBudgets.current(userID, teamID, sessionID)
|
|
1619
|
+
if (!current) return undefined
|
|
1620
|
+
const eligible = await itemChecks.eligible(userID, current, [sessionID])
|
|
1621
|
+
if (!eligible.ok || eligible.teamID !== teamID) return undefined
|
|
1622
|
+
const root = eligible.parentItem ?? (eligible.child ? undefined : current)
|
|
1623
|
+
if (!root) return undefined
|
|
1624
|
+
if (root === current) return root
|
|
1625
|
+
const parent = await itemChecks.eligible(userID, root, [sessionID])
|
|
1626
|
+
return parent.ok && !parent.child && parent.teamID === teamID ? root : undefined
|
|
1627
|
+
},
|
|
1628
|
+
routed: (userID, teamID, intakeID, sessionID) => itemBudgets.setCurrent(userID, teamID, sessionID, intakeID),
|
|
1629
|
+
progressChanged: ({ userID, teamID, item, sessionID, revision, status, actor }) => progressSignals.intake({ userID, teamID, item, revision, status, actor: actor.kind === "session" ? { kind: "session", sessionID: actor.sessionID } : { kind: actor.kind }, ...(sessionID ? { sessionID } : {}) }),
|
|
1630
|
+
submittedByUser: (userID, teamID, intakeID, actor) => actor.via === "cookie" ? teamInvolvement.involved(userID, { kind: "intake", teamIDs: [teamID], ref: intakeID, actor: { deviceID: actor.deviceID, via: actor.via } }) : Promise.resolve(),
|
|
1387
1631
|
state: backends.state,
|
|
1388
1632
|
teams: teamStore,
|
|
1389
1633
|
seats: teamSeats,
|
|
@@ -1435,6 +1679,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1435
1679
|
const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
|
|
1436
1680
|
subscriptions = new Subscriptions({
|
|
1437
1681
|
state: backends.state,
|
|
1682
|
+
hopLimit: config.hopLimit,
|
|
1683
|
+
currentHopLimit: () => agentLimits.snapshot().hopLimit,
|
|
1438
1684
|
events: agentEvents,
|
|
1439
1685
|
send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
|
|
1440
1686
|
hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
|
|
@@ -1744,7 +1990,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1744
1990
|
conversationOf: (sessionID) => registry.conversationOf(sessionID),
|
|
1745
1991
|
stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
|
|
1746
1992
|
}
|
|
1747
|
-
const agentDeps = { contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, 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, subscriptions, 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(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
|
|
1993
|
+
const agentDeps = { nodes, agentLimits, contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, 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, subscriptions, 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(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
|
|
1748
1994
|
const resolveActor = createResolver({
|
|
1749
1995
|
cookieName: COOKIE,
|
|
1750
1996
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -1777,9 +2023,28 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1777
2023
|
|
|
1778
2024
|
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
1779
2025
|
const url = new URL(request.url)
|
|
2026
|
+
// /api/v1/<rest> is /api/<rest> of the two route tables, its errors in one envelope (./api-v1.ts). The handlers
|
|
2027
|
+
// get the unversioned URL and the request itself (server.timeout and the body belong to that object).
|
|
2028
|
+
const unversioned = unversionedURL(url)
|
|
2029
|
+
if (!unversioned) return await respond(request, server, url, false)
|
|
2030
|
+
return await apiV1Response((await respond(request, server, unversioned, true)) ?? error(404, "Not found"))
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
async function respond(request: Request, server: Server<SpokeSocketData>, url: URL, v1: boolean): Promise<Response | undefined> {
|
|
1780
2034
|
const { pathname } = url
|
|
1781
2035
|
const method = request.method
|
|
2036
|
+
if (!agentLimits.initialLoaded() && !((method === "GET" || method === "HEAD") && (pathname === "/healthz" || pathname === "/readyz"))) {
|
|
2037
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2038
|
+
await Promise.race([agentLimitsReady, new Promise<void>((resolve) => { timer = setTimeout(resolve, options.timing?.agentLimitsInitialWaitMs ?? 5_000) })])
|
|
2039
|
+
if (timer) clearTimeout(timer)
|
|
2040
|
+
if (!agentLimits.initialLoaded()) return Response.json({ error: "Agent limits unavailable", code: "busy" }, { status: 503, headers: { ...NO_STORE, "retry-after": String(agentLimits.retryAfter()) } })
|
|
2041
|
+
}
|
|
1782
2042
|
try {
|
|
2043
|
+
// A /api/v1 request reaches the two route tables only: none of the endpoints below and never a static file.
|
|
2044
|
+
if (v1) {
|
|
2045
|
+
await store.ready()
|
|
2046
|
+
return isAgentPath(pathname) ? await agent.handle(request, url) : await api(request, server, url)
|
|
2047
|
+
}
|
|
1783
2048
|
// HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
|
|
1784
2049
|
if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
|
|
1785
2050
|
const response = pathname === "/readyz" ? await readyz() : healthz()
|
|
@@ -2026,16 +2291,18 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2026
2291
|
await store.removeDevice(device.id)
|
|
2027
2292
|
return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
|
|
2028
2293
|
}),
|
|
2029
|
-
route("POST", "/api/transcribe", "user", ({ request, user, device }) => transcribe(request, speechFor(user.id), user.id, device.id)),
|
|
2030
|
-
route("POST", "/api/speak", "user",
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2294
|
+
route("POST", "/api/transcribe", "user", ({ request, user, device }) => requestLined(user.id, device.id, request, "transcribe", () => transcribe(request, speechFor(user.id), user.id, device.id))),
|
|
2295
|
+
route("POST", "/api/speak", "user", ({ request, user, device }) =>
|
|
2296
|
+
requestLined(user.id, device.id, request, "speak", async () => {
|
|
2297
|
+
const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
|
|
2298
|
+
if (!text) return error(400, "Nothing to speak")
|
|
2299
|
+
const speech = speechFor(user.id)
|
|
2300
|
+
// (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; in Developer mode the audio is kept too.
|
|
2301
|
+
const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
|
|
2302
|
+
const audio = await speakAudio(speech, text, request.signal)
|
|
2303
|
+
return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
|
|
2304
|
+
}),
|
|
2305
|
+
),
|
|
2039
2306
|
// (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
|
|
2040
2307
|
...recordingRoutes(recordingService, { version: options.version ?? "0.0.0", now, audit: (entry) => store.audit(entry) }).map((r) => route(r.method, r.path, "user", (context) => r.handle(context))),
|
|
2041
2308
|
route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
|
|
@@ -2151,7 +2418,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2151
2418
|
registry.setPresence(device.id, params.session!, visible)
|
|
2152
2419
|
return new Response(null, { status: 204 })
|
|
2153
2420
|
}),
|
|
2154
|
-
route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params }) => turn(request, server, params.session
|
|
2421
|
+
route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params, device }) => turn(request, server, params.session!, device.id)),
|
|
2155
2422
|
route("POST", "/api/sessions/:session/utterance", "session", ({ request, params, device }) => utterance(request, params.session!, device.id), true),
|
|
2156
2423
|
route(
|
|
2157
2424
|
"POST",
|
|
@@ -2261,8 +2528,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2261
2528
|
"session",
|
|
2262
2529
|
async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
|
|
2263
2530
|
),
|
|
2264
|
-
route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
|
|
2265
|
-
route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
|
|
2531
|
+
route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user, device }) => permission(request, params.session!, user.id, device.id)),
|
|
2532
|
+
route("POST", "/api/sessions/:session/form", "session", ({ request, params, user, device }) => form(request, params.session!, user.id, device.id)),
|
|
2266
2533
|
route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
|
|
2267
2534
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
|
|
2268
2535
|
// Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
|
|
@@ -2336,6 +2603,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2336
2603
|
await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
|
|
2337
2604
|
return Response.json({ enabled }, { headers: NO_STORE })
|
|
2338
2605
|
}),
|
|
2606
|
+
// RB-1 (plan sjaqp2ay): a team's hop depths, and the user's Renew button. Renew is cookie only (a device token
|
|
2607
|
+
// gets 403; session, app and Concierge tokens never reach console routes) and strict: a failed reset → 500.
|
|
2608
|
+
route("GET", "/api/teams/:id/hops", "user", async ({ params, user }) => teamHopsAnswer(user.id, params.id!)),
|
|
2609
|
+
route("POST", "/api/teams/:id/renew", "user", async ({ request, params, user, device }) => {
|
|
2610
|
+
if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Renew a team from the console", "forbidden")
|
|
2611
|
+
return teamHopsAnswer(user.id, params.id!, (teamID) => teamInvolvement.renewManual(user.id, teamID, { deviceID: device.id, via: "cookie" }))
|
|
2612
|
+
}),
|
|
2339
2613
|
// Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
|
|
2340
2614
|
route("GET", "/api/decisions", "user", async ({ url, user }) => {
|
|
2341
2615
|
try {
|
|
@@ -2356,13 +2630,28 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2356
2630
|
try {
|
|
2357
2631
|
const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
|
|
2358
2632
|
if (answered.reply) feed.publish(user.id, answered.reply)
|
|
2359
|
-
await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answered.decision.answer
|
|
2633
|
+
await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answerDetail(answered.decision.answer) })
|
|
2360
2634
|
return Response.json(answered, { headers: NO_STORE })
|
|
2361
2635
|
} catch (cause) {
|
|
2362
2636
|
return decisionFailure(cause)
|
|
2363
2637
|
}
|
|
2364
2638
|
}),
|
|
2365
2639
|
route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
|
|
2640
|
+
route("GET", "/api/settings/agent-limits", "admin", async () => Response.json(await agentLimits.view(), { headers: NO_STORE })),
|
|
2641
|
+
route("PUT", "/api/settings/agent-limits", "admin", async ({ request, user }) => {
|
|
2642
|
+
// (HC-1) The safety limits change only from the signed-in console (the cookie), never a device token.
|
|
2643
|
+
if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Agent limits change only in the signed-in console", "forbidden", NO_STORE)
|
|
2644
|
+
if (!agentLimits.initialLoaded()) return Response.json({ error: "Agent limits unavailable", code: "busy" }, { status: 503, headers: { ...NO_STORE, "retry-after": String(agentLimits.retryAfter()) } })
|
|
2645
|
+
const before = await agentLimits.view()
|
|
2646
|
+
try {
|
|
2647
|
+
const after = await agentLimits.update(await jsonBody(request))
|
|
2648
|
+
await store.audit({ actor: user.id, action: "agents.limits.set", detail: JSON.stringify({ before: before.invalidStored ? "invalid" : before, after }) })
|
|
2649
|
+
return Response.json(after, { headers: NO_STORE })
|
|
2650
|
+
} catch (cause) {
|
|
2651
|
+
if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
|
|
2652
|
+
throw cause
|
|
2653
|
+
}
|
|
2654
|
+
}),
|
|
2366
2655
|
route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
|
|
2367
2656
|
const view = await speechSettings.update(await jsonBody(request))
|
|
2368
2657
|
await store.audit({ actor: user.id, action: "speech.default" })
|
|
@@ -2435,6 +2724,22 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2435
2724
|
return new Response(null, { status: 204 })
|
|
2436
2725
|
}
|
|
2437
2726
|
|
|
2727
|
+
/** The team's effective hop limit, its seats' depths and whether a hop-cap Decision is open (after `before`). */
|
|
2728
|
+
async function teamHopsAnswer(userID: string, id: string, before?: (teamID: string) => Promise<void>): Promise<Response> {
|
|
2729
|
+
try {
|
|
2730
|
+
const team = (await teamStore.get(userID, id)) ?? (await teamStore.byName(userID, id))
|
|
2731
|
+
if (!team) return error(404, "No such team", "not_found", NO_STORE)
|
|
2732
|
+
await before?.(team.id)
|
|
2733
|
+
const global = agentLimits.snapshot().hopLimit
|
|
2734
|
+
const current = await teamSeats!.list(userID, team.id, { current: true })
|
|
2735
|
+
const seats = await Promise.all(current.map(async (seat) => ({ sessionID: seat.sessionID, role: seat.role, depth: await delivery.hops.depth(userID, { kind: "session", sessionID: seat.sessionID }) })))
|
|
2736
|
+
return Response.json({ limit: Math.min(team.hopLimit ?? global, global), maxDepth: Math.max(0, ...seats.map((seat) => seat.depth)), seats, stalled: await teamHopStalls.open(userID, team.id) }, { headers: NO_STORE })
|
|
2737
|
+
} catch (cause) {
|
|
2738
|
+
if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
|
|
2739
|
+
throw cause
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2438
2743
|
/** A connect-flow handler's AgentError as the console's error envelope. */
|
|
2439
2744
|
async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
|
|
2440
2745
|
try {
|
|
@@ -2526,7 +2831,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2526
2831
|
return Response.json(userRow(store.user(id)!))
|
|
2527
2832
|
}
|
|
2528
2833
|
|
|
2529
|
-
async function turn(request: Request, server: Server<SpokeSocketData>, sessionID: string): Promise<Response> {
|
|
2834
|
+
async function turn(request: Request, server: Server<SpokeSocketData>, sessionID: string, deviceID: string): Promise<Response> {
|
|
2530
2835
|
const input = await body(request)
|
|
2531
2836
|
const raw = input.text
|
|
2532
2837
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
@@ -2545,6 +2850,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2545
2850
|
const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
2546
2851
|
if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
|
|
2547
2852
|
if (started.outcome === "unsupported") throw unsupportedError(started)
|
|
2853
|
+
const tenant = registry.sessionTenant(sessionID)
|
|
2854
|
+
if (tenant !== undefined && (await resolveActor.mode(request)) === "cookie") await teamInvolvement.involved(tenant, { kind: "prompt", sessionID, ref: "console-turn", actor: { deviceID, via: "cookie" } })
|
|
2548
2855
|
if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
|
|
2549
2856
|
const result = started.result
|
|
2550
2857
|
if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
|
|
@@ -2555,7 +2862,6 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2555
2862
|
if ("cause" in early) throw early.cause
|
|
2556
2863
|
return Response.json(early.value)
|
|
2557
2864
|
}
|
|
2558
|
-
const tenant = registry.sessionTenant(sessionID)
|
|
2559
2865
|
void result.then(
|
|
2560
2866
|
(value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
|
|
2561
2867
|
(cause: unknown) => {
|
|
@@ -2837,7 +3143,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2837
3143
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
2838
3144
|
if (!text) return error(400, "Empty utterance")
|
|
2839
3145
|
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
2840
|
-
|
|
3146
|
+
const inboxID = await sendUtterance(sessionID, text, undefined, deviceID)
|
|
3147
|
+
const tenant = registry.sessionTenant(sessionID)
|
|
3148
|
+
if (tenant !== undefined && (await resolveActor.mode(request)) === "cookie") await teamInvolvement.involved(tenant, { kind: "prompt", sessionID, ref: "utterance", actor: { ...(deviceID ? { deviceID } : {}), via: "cookie" } })
|
|
3149
|
+
return Response.json({ inboxID }, { status: 202 })
|
|
2841
3150
|
}
|
|
2842
3151
|
|
|
2843
3152
|
/**
|
|
@@ -3120,7 +3429,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3120
3429
|
const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
|
|
3121
3430
|
void audit.record(userID, { actor: { kind: "user" }, verb, target, decision: "allow", outcome: "ok" }).catch((cause: unknown) => console.error(`[hub] audit of a console answer failed: ${(cause as Error).message}`))
|
|
3122
3431
|
|
|
3123
|
-
async function permission(request: Request, rootID: string, userID: string): Promise<Response> {
|
|
3432
|
+
async function permission(request: Request, rootID: string, userID: string, deviceID: string): Promise<Response> {
|
|
3124
3433
|
const input = await body(request)
|
|
3125
3434
|
const decision = input.decision as PermissionDecision
|
|
3126
3435
|
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
@@ -3128,12 +3437,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3128
3437
|
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
3129
3438
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
3130
3439
|
if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
|
|
3440
|
+
if (answered.outcome === "done" && (await resolveActor.mode(request)) === "cookie") {
|
|
3441
|
+
await teamInvolvement.involved(userID, { kind: "permission", sessionID: asking, ref: input.requestID, actor: { deviceID, via: "cookie" } })
|
|
3442
|
+
await progressSignals.consoleAnswered(userID, { sessionID: asking, via: "cookie", ref: `permission:${input.requestID}`, kind: "approval" }).catch((cause) => console.error("[hub] approval progress failed:", cause))
|
|
3443
|
+
}
|
|
3131
3444
|
if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
|
|
3132
3445
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
3133
3446
|
}
|
|
3134
3447
|
|
|
3135
3448
|
/** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
|
|
3136
|
-
async function form(request: Request, rootID: string, userID: string): Promise<Response> {
|
|
3449
|
+
async function form(request: Request, rootID: string, userID: string, deviceID: string): Promise<Response> {
|
|
3137
3450
|
const input = await body(request)
|
|
3138
3451
|
if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
|
|
3139
3452
|
const cancel = input.cancel === true
|
|
@@ -3148,10 +3461,36 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3148
3461
|
)
|
|
3149
3462
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
3150
3463
|
if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
|
|
3464
|
+
if (answered.outcome === "done" && (await resolveActor.mode(request)) === "cookie") {
|
|
3465
|
+
await teamInvolvement.involved(userID, { kind: "form", sessionID: asking, ref: input.formID, actor: { deviceID, via: "cookie" } })
|
|
3466
|
+
await progressSignals.consoleAnswered(userID, { sessionID: asking, via: "cookie", ref: `form:${input.formID}`, kind: "approval" }).catch((cause) => console.error("[hub] approval progress failed:", cause))
|
|
3467
|
+
}
|
|
3151
3468
|
if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
|
|
3152
3469
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
3153
3470
|
}
|
|
3154
3471
|
|
|
3472
|
+
/**
|
|
3473
|
+
* (Developer mode) A voice request naming a recording (x-hub-recording): its route, status and time are recorded
|
|
3474
|
+
* as a request line in that recording when it is a developer one (recordingService.requestLine). Never a body.
|
|
3475
|
+
*/
|
|
3476
|
+
async function requestLined(userID: string, deviceID: string, request: Request, route: "transcribe" | "speak", handle: () => Promise<Response>): Promise<Response> {
|
|
3477
|
+
const recordingID = request.headers.get(RECORDING_HEADER)
|
|
3478
|
+
if (recordingID === null) return handle()
|
|
3479
|
+
const started = performance.now()
|
|
3480
|
+
let status = 500
|
|
3481
|
+
try {
|
|
3482
|
+
const res = await handle()
|
|
3483
|
+
status = res.status
|
|
3484
|
+
return res
|
|
3485
|
+
} catch (cause) {
|
|
3486
|
+
const s = (cause as { status?: unknown }).status
|
|
3487
|
+
status = cause instanceof SpeechError ? 502 : typeof s === "number" ? s : 500
|
|
3488
|
+
throw cause
|
|
3489
|
+
} finally {
|
|
3490
|
+
void recordingService.requestLine(userID, deviceID, recordingID, { route, method: "POST", status, ms: performance.now() - started }).catch(() => undefined)
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3155
3494
|
async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
|
|
3156
3495
|
const format = audioFormat(request.headers.get("content-type"))
|
|
3157
3496
|
if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
|
|
@@ -3163,9 +3502,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3163
3502
|
// (REC-01) With x-hub-recording + x-hub-segment: the segment is kept for the recording (no second upload).
|
|
3164
3503
|
const tap = await recordingService.transcribeTap(userID, deviceID, request.headers, audio, format).catch(() => undefined)
|
|
3165
3504
|
const started = performance.now()
|
|
3166
|
-
const
|
|
3505
|
+
const stats: TranscribeStats = {}
|
|
3506
|
+
const text = await (tap ? speech.transcribe(audio, format, stats) : speech.transcribe(audio, format))
|
|
3167
3507
|
if (!tap) return Response.json({ text })
|
|
3168
|
-
const result = await tap.finish(text, Math.round(performance.now() - started)).catch(() => "failed" as const)
|
|
3508
|
+
const result = await tap.finish(text, Math.round(performance.now() - started), stats).catch(() => "failed" as const)
|
|
3169
3509
|
return Response.json({ text }, { headers: { [RECORDING_AUDIO_RESULT_HEADER]: result } })
|
|
3170
3510
|
}
|
|
3171
3511
|
|
|
@@ -3294,6 +3634,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3294
3634
|
}
|
|
3295
3635
|
|
|
3296
3636
|
async function readyz(): Promise<Response> {
|
|
3637
|
+
if (!agentLimits.initialLoaded()) return Response.json({ ok: false, error: "Agent limits unavailable" }, { status: 503, headers: NO_STORE })
|
|
3297
3638
|
if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
|
|
3298
3639
|
if (config.cluster) {
|
|
3299
3640
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -3392,6 +3733,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3392
3733
|
push,
|
|
3393
3734
|
speechSettings,
|
|
3394
3735
|
backends,
|
|
3736
|
+
agentLimits,
|
|
3395
3737
|
delivery,
|
|
3396
3738
|
decisions,
|
|
3397
3739
|
acting,
|
|
@@ -3414,6 +3756,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3414
3756
|
return draining
|
|
3415
3757
|
},
|
|
3416
3758
|
async stop() {
|
|
3759
|
+
await agentLimits.stop()
|
|
3417
3760
|
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
3418
3761
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
3419
3762
|
launches.shutdown()
|
|
@@ -3451,12 +3794,13 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
|
|
|
3451
3794
|
}
|
|
3452
3795
|
|
|
3453
3796
|
/** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
|
|
3454
|
-
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; via?: AnswerVia } {
|
|
3797
|
+
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; picks?: Record<string, string>; via?: AnswerVia } {
|
|
3455
3798
|
for (const key of ["option", "text"] as const) {
|
|
3456
3799
|
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
3457
3800
|
}
|
|
3458
3801
|
if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
|
|
3459
|
-
|
|
3802
|
+
const picks = answerPicks(input.picks)
|
|
3803
|
+
return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(picks ? { picks } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
|
|
3460
3804
|
}
|
|
3461
3805
|
|
|
3462
3806
|
/**
|