@huynle/hub 0.11.0 → 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 +26 -0
- package/README.md +19 -5
- 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 +240 -83
- 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/mcp.ts +20 -4
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +7 -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 +13 -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 +11 -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 +2 -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 +130 -9
- package/src/central/agent/delivery.ts +134 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/launch-engine.ts +6 -3
- package/src/central/agent/message-limits.ts +5 -2
- 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 +2 -0
- package/src/central/agent/subscriptions.ts +13 -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 +20 -6
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +285 -23
- package/src/central/recording/index.ts +2 -1
- package/src/central/recording/schema.ts +2 -1
- package/src/central/registry.ts +34 -3
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +14 -6
- 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/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -2
- package/src/central/teams/store.ts +21 -5
- package/src/central/teams/validation.ts +17 -6
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-7jpOc2HM.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/index-DA0gKIzv.js +33 -0
- 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/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
- package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
- package/web/dist/assets/index-Da8SKXMw.js +0 -33
- package/web/dist/assets/live-BmqLASSo.js +0 -5
package/src/central/http.ts
CHANGED
|
@@ -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"
|
|
@@ -101,6 +103,15 @@ import { TeamSeatStore } from "./teams/seats"
|
|
|
101
103
|
import { TeamStaffingService } from "./teams/staffing"
|
|
102
104
|
import { intakeActor, TeamIntakeService } from "./teams/intake"
|
|
103
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"
|
|
104
115
|
import { TeamStore } from "./teams/store"
|
|
105
116
|
import { ActivityTracker } from "./concierge/activity"
|
|
106
117
|
import { HumanActivity, withHumanActivity } from "./human-activity"
|
|
@@ -110,7 +121,7 @@ import { conciergeBoard } from "./concierge/board-routes"
|
|
|
110
121
|
import { renderFrontBoard } from "./concierge/board-render"
|
|
111
122
|
import { ContextPusher } from "./concierge/context"
|
|
112
123
|
import { C1_ENTITLEMENTS } from "./concierge/entitlements"
|
|
113
|
-
import { ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
|
|
124
|
+
import { cardTriggerOf, ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
|
|
114
125
|
import { TRANSCRIPT_ROWS } from "./context-cards/inputs"
|
|
115
126
|
import { CONTEXT_CARD_TRIGGERS } from "./../protocol"
|
|
116
127
|
import type { ChatClient } from "./llm/chat"
|
|
@@ -204,6 +215,10 @@ export interface CentralOptions {
|
|
|
204
215
|
readonly leaderTtlMs?: number
|
|
205
216
|
/** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
|
|
206
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
|
|
207
222
|
/** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
|
|
208
223
|
readonly drainTimeoutMs?: number
|
|
209
224
|
readonly drainRetryAfterMs?: number
|
|
@@ -226,6 +241,7 @@ export interface Central {
|
|
|
226
241
|
readonly speechSettings: SpeechService
|
|
227
242
|
/** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
|
|
228
243
|
readonly backends: Backends
|
|
244
|
+
readonly agentLimits: AgentLimits
|
|
229
245
|
/** Agent messages: store and delivery to sessions (the message routes use it). */
|
|
230
246
|
readonly delivery: Delivery
|
|
231
247
|
/** Decisions (ask @me): answered by the user on /api/decisions. */
|
|
@@ -470,6 +486,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
470
486
|
/** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
|
|
471
487
|
const ownBackends = !options.backends
|
|
472
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
|
+
})
|
|
473
494
|
// Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
|
|
474
495
|
// migration runs on start). Otherwise state.json in the state dir.
|
|
475
496
|
const store = config.cluster
|
|
@@ -892,7 +913,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
892
913
|
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
893
914
|
// Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
|
|
894
915
|
let teamSeats: TeamSeatStore | undefined
|
|
895
|
-
const teamStore = new TeamStore({ state: backends.state, now,
|
|
916
|
+
const teamStore = new TeamStore({ state: backends.state, now, hopCeiling: () => agentLimits.snapshot().hopCeiling, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
896
917
|
const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
|
|
897
918
|
teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
|
|
898
919
|
const teamPolicy = new TeamPolicy({
|
|
@@ -909,9 +930,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
909
930
|
const teamHopStalls = new TeamHopStalls({
|
|
910
931
|
state: backends.state,
|
|
911
932
|
now,
|
|
912
|
-
|
|
913
|
-
reset: (userID, sessionID) => delivery.hops.humanTurn(userID, sessionID),
|
|
914
|
-
close: async (userID, decisionID) => { await decisions.close(userID, decisionID, "A person continued this team's work; renewal is no longer needed") },
|
|
933
|
+
close: async (userID, decisionID, reason) => { await decisions.close(userID, decisionID, reason ?? "A person continued this team's work; renewal is no longer needed") },
|
|
915
934
|
notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
|
|
916
935
|
open: async (userID, teamID, sender, recipient, limit) => {
|
|
917
936
|
const team = await teamStore.require(userID, teamID)
|
|
@@ -919,15 +938,149 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
919
938
|
const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
|
|
920
939
|
const detail = active ? `, intake ${active.id} (${active.title})` : ""
|
|
921
940
|
const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-hop", scopes: [], label: "Hub team hop cap" }, {
|
|
922
|
-
question: `Team #${team.name}${detail} reached its ${limit}-hop limit (${sender} → ${recipient}). Renew this team's seats?`,
|
|
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?`,
|
|
923
942
|
options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
|
|
924
943
|
urgency: "high", tier: "tap", about: [recipient],
|
|
925
944
|
}, { action: { kind: "teamHopRenew", teamID } })
|
|
926
945
|
return decision.id
|
|
927
946
|
},
|
|
928
947
|
})
|
|
929
|
-
const
|
|
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
|
+
},
|
|
930
1082
|
hopLimit: config.hopLimit,
|
|
1083
|
+
currentHopLimit: () => agentLimits.snapshot().hopLimit,
|
|
931
1084
|
teams: teamPolicy,
|
|
932
1085
|
teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
|
|
933
1086
|
teamHopNotice: async (userID, teamID) => {
|
|
@@ -969,6 +1122,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
969
1122
|
let conciergeTierHooks: ConciergeTiers | undefined
|
|
970
1123
|
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
971
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
|
+
},
|
|
972
1130
|
state: backends.state,
|
|
973
1131
|
delivery,
|
|
974
1132
|
now,
|
|
@@ -977,6 +1135,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
977
1135
|
// The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
|
|
978
1136
|
changed: (userID, decision) => {
|
|
979
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))
|
|
980
1139
|
conciergeTierHooks?.decisionChanged?.(userID, decision)
|
|
981
1140
|
// (REC-01) decision.opened / decision.answered of a recorded session (best effort).
|
|
982
1141
|
recordings?.decisionChanged(userID, decision)
|
|
@@ -989,9 +1148,17 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
989
1148
|
if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
|
|
990
1149
|
const teamID = decision.action!.teamID
|
|
991
1150
|
return choice.option === "renew"
|
|
992
|
-
?
|
|
1151
|
+
? teamInvolvement.renew(userID, teamID, decision.id, { deviceID: by.deviceID, via: by.via }).then(() => "done" as const)
|
|
993
1152
|
: Promise.resolve("done" as const)
|
|
994
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
|
+
}
|
|
995
1162
|
if (kind === "act") {
|
|
996
1163
|
if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
|
|
997
1164
|
return conciergeTierHooks.act(userID, decision, choice, by)
|
|
@@ -1025,6 +1192,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1025
1192
|
void revertWindows.observe(tenant, sessionID, committed).catch((cause) => console.error(`[hub] revert window not kept: ${(cause as Error).message}`))
|
|
1026
1193
|
}
|
|
1027
1194
|
})
|
|
1195
|
+
// Spoke-origin human-turn evidence retains its existing per-seat reset only; it is not console authentication.
|
|
1028
1196
|
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
1029
1197
|
// (Session findability, decision iaspcry7) "Touched by me" and the activity meter: per user, per session; the
|
|
1030
1198
|
// owner's consoles get each change as {type: "activity", sessionID, activity}.
|
|
@@ -1101,6 +1269,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1101
1269
|
locks: backends.locks,
|
|
1102
1270
|
owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
|
|
1103
1271
|
...(config.launchCaps ? { caps: config.launchCaps } : {}),
|
|
1272
|
+
currentCaps: () => agentLimits.snapshot().launches,
|
|
1104
1273
|
nodes,
|
|
1105
1274
|
nodeOps: spokes,
|
|
1106
1275
|
registry,
|
|
@@ -1211,7 +1380,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1211
1380
|
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
1212
1381
|
kv: backends.kv,
|
|
1213
1382
|
now,
|
|
1214
|
-
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))),
|
|
1215
1384
|
})
|
|
1216
1385
|
const agentEventWriters = new EventWriters({
|
|
1217
1386
|
events: agentEvents,
|
|
@@ -1235,7 +1404,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1235
1404
|
workspaces: artifactWorkspacesFromState(backends.state),
|
|
1236
1405
|
...(options.previewRetention?.archiveAfterMs !== undefined ? { previewArchiveAfterMs: options.previewRetention.archiveAfterMs } : {}),
|
|
1237
1406
|
...(options.previewRetention?.purgeAfterMs !== undefined ? { previewPurgeAfterMs: options.previewRetention.purgeAfterMs } : {}),
|
|
1238
|
-
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
|
+
},
|
|
1239
1412
|
})
|
|
1240
1413
|
: undefined
|
|
1241
1414
|
const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
|
|
@@ -1440,6 +1613,21 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1440
1613
|
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
|
|
1441
1614
|
// message inherits the item's notAfter (H4); queued items past it are cancelled by the team-intake leader's sweep.
|
|
1442
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(),
|
|
1443
1631
|
state: backends.state,
|
|
1444
1632
|
teams: teamStore,
|
|
1445
1633
|
seats: teamSeats,
|
|
@@ -1492,6 +1680,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1492
1680
|
subscriptions = new Subscriptions({
|
|
1493
1681
|
state: backends.state,
|
|
1494
1682
|
hopLimit: config.hopLimit,
|
|
1683
|
+
currentHopLimit: () => agentLimits.snapshot().hopLimit,
|
|
1495
1684
|
events: agentEvents,
|
|
1496
1685
|
send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
|
|
1497
1686
|
hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
|
|
@@ -1801,7 +1990,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1801
1990
|
conversationOf: (sessionID) => registry.conversationOf(sessionID),
|
|
1802
1991
|
stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
|
|
1803
1992
|
}
|
|
1804
|
-
const agentDeps = { nodes, 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 } }
|
|
1805
1994
|
const resolveActor = createResolver({
|
|
1806
1995
|
cookieName: COOKIE,
|
|
1807
1996
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -1834,9 +2023,28 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1834
2023
|
|
|
1835
2024
|
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
1836
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> {
|
|
1837
2034
|
const { pathname } = url
|
|
1838
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
|
+
}
|
|
1839
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
|
+
}
|
|
1840
2048
|
// HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
|
|
1841
2049
|
if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
|
|
1842
2050
|
const response = pathname === "/readyz" ? await readyz() : healthz()
|
|
@@ -2210,7 +2418,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2210
2418
|
registry.setPresence(device.id, params.session!, visible)
|
|
2211
2419
|
return new Response(null, { status: 204 })
|
|
2212
2420
|
}),
|
|
2213
|
-
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)),
|
|
2214
2422
|
route("POST", "/api/sessions/:session/utterance", "session", ({ request, params, device }) => utterance(request, params.session!, device.id), true),
|
|
2215
2423
|
route(
|
|
2216
2424
|
"POST",
|
|
@@ -2320,8 +2528,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2320
2528
|
"session",
|
|
2321
2529
|
async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
|
|
2322
2530
|
),
|
|
2323
|
-
route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
|
|
2324
|
-
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)),
|
|
2325
2533
|
route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
|
|
2326
2534
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
|
|
2327
2535
|
// Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
|
|
@@ -2395,6 +2603,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2395
2603
|
await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
|
|
2396
2604
|
return Response.json({ enabled }, { headers: NO_STORE })
|
|
2397
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
|
+
}),
|
|
2398
2613
|
// Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
|
|
2399
2614
|
route("GET", "/api/decisions", "user", async ({ url, user }) => {
|
|
2400
2615
|
try {
|
|
@@ -2415,13 +2630,28 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2415
2630
|
try {
|
|
2416
2631
|
const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
|
|
2417
2632
|
if (answered.reply) feed.publish(user.id, answered.reply)
|
|
2418
|
-
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) })
|
|
2419
2634
|
return Response.json(answered, { headers: NO_STORE })
|
|
2420
2635
|
} catch (cause) {
|
|
2421
2636
|
return decisionFailure(cause)
|
|
2422
2637
|
}
|
|
2423
2638
|
}),
|
|
2424
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
|
+
}),
|
|
2425
2655
|
route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
|
|
2426
2656
|
const view = await speechSettings.update(await jsonBody(request))
|
|
2427
2657
|
await store.audit({ actor: user.id, action: "speech.default" })
|
|
@@ -2494,6 +2724,22 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2494
2724
|
return new Response(null, { status: 204 })
|
|
2495
2725
|
}
|
|
2496
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
|
+
|
|
2497
2743
|
/** A connect-flow handler's AgentError as the console's error envelope. */
|
|
2498
2744
|
async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
|
|
2499
2745
|
try {
|
|
@@ -2585,7 +2831,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2585
2831
|
return Response.json(userRow(store.user(id)!))
|
|
2586
2832
|
}
|
|
2587
2833
|
|
|
2588
|
-
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> {
|
|
2589
2835
|
const input = await body(request)
|
|
2590
2836
|
const raw = input.text
|
|
2591
2837
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
@@ -2604,6 +2850,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2604
2850
|
const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
2605
2851
|
if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
|
|
2606
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" } })
|
|
2607
2855
|
if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
|
|
2608
2856
|
const result = started.result
|
|
2609
2857
|
if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
|
|
@@ -2614,7 +2862,6 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2614
2862
|
if ("cause" in early) throw early.cause
|
|
2615
2863
|
return Response.json(early.value)
|
|
2616
2864
|
}
|
|
2617
|
-
const tenant = registry.sessionTenant(sessionID)
|
|
2618
2865
|
void result.then(
|
|
2619
2866
|
(value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
|
|
2620
2867
|
(cause: unknown) => {
|
|
@@ -2896,7 +3143,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2896
3143
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
2897
3144
|
if (!text) return error(400, "Empty utterance")
|
|
2898
3145
|
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
2899
|
-
|
|
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 })
|
|
2900
3150
|
}
|
|
2901
3151
|
|
|
2902
3152
|
/**
|
|
@@ -3179,7 +3429,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3179
3429
|
const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
|
|
3180
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}`))
|
|
3181
3431
|
|
|
3182
|
-
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> {
|
|
3183
3433
|
const input = await body(request)
|
|
3184
3434
|
const decision = input.decision as PermissionDecision
|
|
3185
3435
|
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
@@ -3187,12 +3437,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3187
3437
|
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
3188
3438
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
3189
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
|
+
}
|
|
3190
3444
|
if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
|
|
3191
3445
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
3192
3446
|
}
|
|
3193
3447
|
|
|
3194
3448
|
/** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
|
|
3195
|
-
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> {
|
|
3196
3450
|
const input = await body(request)
|
|
3197
3451
|
if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
|
|
3198
3452
|
const cancel = input.cancel === true
|
|
@@ -3207,6 +3461,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3207
3461
|
)
|
|
3208
3462
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
3209
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
|
+
}
|
|
3210
3468
|
if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
|
|
3211
3469
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
3212
3470
|
}
|
|
@@ -3376,6 +3634,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3376
3634
|
}
|
|
3377
3635
|
|
|
3378
3636
|
async function readyz(): Promise<Response> {
|
|
3637
|
+
if (!agentLimits.initialLoaded()) return Response.json({ ok: false, error: "Agent limits unavailable" }, { status: 503, headers: NO_STORE })
|
|
3379
3638
|
if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
|
|
3380
3639
|
if (config.cluster) {
|
|
3381
3640
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -3474,6 +3733,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3474
3733
|
push,
|
|
3475
3734
|
speechSettings,
|
|
3476
3735
|
backends,
|
|
3736
|
+
agentLimits,
|
|
3477
3737
|
delivery,
|
|
3478
3738
|
decisions,
|
|
3479
3739
|
acting,
|
|
@@ -3496,6 +3756,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3496
3756
|
return draining
|
|
3497
3757
|
},
|
|
3498
3758
|
async stop() {
|
|
3759
|
+
await agentLimits.stop()
|
|
3499
3760
|
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
3500
3761
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
3501
3762
|
launches.shutdown()
|
|
@@ -3533,12 +3794,13 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
|
|
|
3533
3794
|
}
|
|
3534
3795
|
|
|
3535
3796
|
/** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
|
|
3536
|
-
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 } {
|
|
3537
3798
|
for (const key of ["option", "text"] as const) {
|
|
3538
3799
|
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
3539
3800
|
}
|
|
3540
3801
|
if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
|
|
3541
|
-
|
|
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 } : {}) }
|
|
3542
3804
|
}
|
|
3543
3805
|
|
|
3544
3806
|
/**
|
|
@@ -735,7 +735,8 @@ export class RecordingService {
|
|
|
735
735
|
if (this.opened.size > 2000) this.opened.delete(this.opened.values().next().value as string)
|
|
736
736
|
this.hub(userID, sessionID, "decision.opened", { decisionID: decision.id, from: partyKey(from), tier: decision.tier, urgency: decision.urgency, options: decision.options.map((o) => o.id), text: decision.question })
|
|
737
737
|
} else if (decision.state === "answered" && decision.answer?.answeredBy === "user") {
|
|
738
|
-
|
|
738
|
+
const picks = decision.answer.picks && Object.entries(decision.answer.picks).map(([group, option]) => ({ group, option }))
|
|
739
|
+
this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), ...(picks ? { picks } : {}), via: decision.answer.via ?? "tap" })
|
|
739
740
|
}
|
|
740
741
|
}
|
|
741
742
|
|
|
@@ -134,7 +134,8 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
|
134
134
|
},
|
|
135
135
|
"resource.timing": { route: oneOf("transcribe", "speak", "events", "utterance", "recording"), n: int, medianMs: num, maxMs: num, "medianTtfbMs?": num, "bytes?": int },
|
|
136
136
|
"console.log": { level: oneOf("error", "warn", "unhandled"), message: text(L.messageChars), "count?": int, "source?": label },
|
|
137
|
-
|
|
137
|
+
// DC-1: a choices Decision is answered with picks (one per group) instead of an option.
|
|
138
|
+
"decision.voice": { decisionID: id, "option?": id, "picks?": list(object({ group: label, option: label }), 12), outcome: oneOf("answered", "refused", "failed") },
|
|
138
139
|
"clock.sync": { t0: num, t1: num, hubAt: num },
|
|
139
140
|
error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
|
|
140
141
|
}
|