@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
|
@@ -79,6 +79,23 @@ export const DECISION_TICK_MS = 30_000
|
|
|
79
79
|
|
|
80
80
|
const OPTION_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/
|
|
81
81
|
|
|
82
|
+
/** (DC-1) Choice groups of a Decision, options per group, and characters of a group's label. */
|
|
83
|
+
export const CHOICES_MAX = 12
|
|
84
|
+
export const CHOICE_OPTIONS_MIN = 2
|
|
85
|
+
export const CHOICE_OPTIONS_MAX = 6
|
|
86
|
+
export const CHOICE_LABEL_MAX = 120
|
|
87
|
+
const CHOICE_ID = /^[A-Za-z0-9_-]{1,8}$/
|
|
88
|
+
/** (DC-1) What an ask is about, for the card's header (absent: a question). */
|
|
89
|
+
export type DecisionKind = "question" | "plan" | "merge" | "release"
|
|
90
|
+
export const DECISION_KINDS: readonly DecisionKind[] = ["question", "plan", "merge", "release"]
|
|
91
|
+
|
|
92
|
+
/** (DC-1) One point of a structured ask: the user picks one of its options. */
|
|
93
|
+
export interface DecisionChoice {
|
|
94
|
+
readonly id: string
|
|
95
|
+
readonly label: string
|
|
96
|
+
readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: true }[]
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
export interface DecisionOption {
|
|
83
100
|
readonly id: string
|
|
84
101
|
readonly label: string
|
|
@@ -88,6 +105,8 @@ export interface DecisionOption {
|
|
|
88
105
|
|
|
89
106
|
export interface DecisionAnswer {
|
|
90
107
|
readonly option?: string
|
|
108
|
+
/** (DC-1) A choices Decision: the option picked in each group (absent: answered with text only). */
|
|
109
|
+
readonly picks?: Readonly<Record<string, string>>
|
|
91
110
|
readonly text?: string
|
|
92
111
|
/** (AF4) "app": an app holding decisions:answer answered a confirm-tier Decision of its own session. */
|
|
93
112
|
readonly answeredBy: "user" | "timeout" | "app"
|
|
@@ -124,7 +143,7 @@ export function appAnswerRefusal(decision: Pick<Decision, "tier" | "action" | "a
|
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
/** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
|
|
127
|
-
export type DecisionAction = PermissionAction | GrantRequestAction | ActAction | TeamHopRenewAction
|
|
146
|
+
export type DecisionAction = PermissionAction | GrantRequestAction | ActAction | TeamHopRenewAction | TeamItemAction
|
|
128
147
|
|
|
129
148
|
/** Hub-created, tap-only action: an agent cannot authorize a team's hop budget renewal. */
|
|
130
149
|
export interface TeamHopRenewAction {
|
|
@@ -132,6 +151,15 @@ export interface TeamHopRenewAction {
|
|
|
132
151
|
readonly teamID: string
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
/** (TA2-7) Hub-created, tap-only: Extend or Stop one intake item that spent its budget or loops without progress. */
|
|
155
|
+
export interface TeamItemAction {
|
|
156
|
+
readonly kind: "teamItem"
|
|
157
|
+
readonly teamID: string
|
|
158
|
+
readonly item: string
|
|
159
|
+
readonly reason: "budget" | "loop"
|
|
160
|
+
readonly pair?: readonly [string, string]
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
/**
|
|
136
164
|
* (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
|
|
137
165
|
* through the acting engine (tenancy re-checked); `hold` does nothing.
|
|
@@ -233,6 +261,9 @@ export interface Decision {
|
|
|
233
261
|
readonly question: string
|
|
234
262
|
readonly options: readonly DecisionOption[]
|
|
235
263
|
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
264
|
+
/** (DC-1) Structured choices (an agent's ask only; never with options or an action). */
|
|
265
|
+
readonly choices?: readonly DecisionChoice[]
|
|
266
|
+
readonly kind?: DecisionKind
|
|
236
267
|
readonly urgency: Urgency
|
|
237
268
|
readonly tier: Tier
|
|
238
269
|
readonly deadline?: number
|
|
@@ -263,6 +294,8 @@ export interface AskInput {
|
|
|
263
294
|
readonly question: string
|
|
264
295
|
readonly options?: readonly { readonly id: string; readonly label: string; readonly safe?: boolean }[]
|
|
265
296
|
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
297
|
+
readonly choices?: readonly { readonly id: string; readonly label: string; readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: boolean }[] }[]
|
|
298
|
+
readonly kind?: DecisionKind
|
|
266
299
|
readonly urgency?: Urgency
|
|
267
300
|
readonly tier?: Tier
|
|
268
301
|
/** From now. */
|
|
@@ -277,6 +310,8 @@ export interface AskInput {
|
|
|
277
310
|
export interface AnswerInput {
|
|
278
311
|
readonly option?: string
|
|
279
312
|
readonly text?: string
|
|
313
|
+
/** (DC-1) One option per choice group. */
|
|
314
|
+
readonly picks?: Readonly<Record<string, string>>
|
|
280
315
|
/** (CON-06) Absent = tap. */
|
|
281
316
|
readonly via?: AnswerVia
|
|
282
317
|
}
|
|
@@ -311,6 +346,8 @@ export interface DecisionsDeps {
|
|
|
311
346
|
readonly changed?: (userID: string, decision: Decision) => void
|
|
312
347
|
/** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
|
|
313
348
|
readonly act?: DecisionActor
|
|
349
|
+
/** Only the authenticated user's answer path; never answerByApp or the deadline sweep. */
|
|
350
|
+
readonly answered?: (userID: string, decision: Decision, by: UserActor) => Promise<void>
|
|
314
351
|
/** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
|
|
315
352
|
readonly via?: ViaCheck
|
|
316
353
|
/** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
|
|
@@ -370,6 +407,9 @@ function validate(input: AskInput) {
|
|
|
370
407
|
if (typeof option !== "string" || !has(option)) throw invalid("The recommendation must name one of the options")
|
|
371
408
|
recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
|
|
372
409
|
}
|
|
410
|
+
const choices = input.choices === undefined ? undefined : validChoices(input.choices)
|
|
411
|
+
if (choices && options.length) throw invalid("An ask offers either options or choices, not both")
|
|
412
|
+
if (input.kind !== undefined && !DECISION_KINDS.includes(input.kind)) throw invalid(`kind is one of ${DECISION_KINDS.join(", ")}`)
|
|
373
413
|
const urgency = input.urgency ?? "normal"
|
|
374
414
|
if (!URGENCIES.includes(urgency)) throw invalid(`urgency is one of ${URGENCIES.join(", ")}`)
|
|
375
415
|
const tier = input.tier ?? "confirm"
|
|
@@ -384,7 +424,62 @@ function validate(input: AskInput) {
|
|
|
384
424
|
}
|
|
385
425
|
const expiresInMs = input.expiresInMs ?? Math.min(ASK_EXPIRES_MAX_MS, Math.max(ASK_EXPIRES_MS, (deadlineMs ?? 0) + ASK_EXPIRES_MS))
|
|
386
426
|
if (deadlineMs !== undefined && deadlineMs > expiresInMs) throw invalid("The deadline is after the ask expires")
|
|
387
|
-
return { options, recommendation, urgency, tier, deadlineMs, onTimeout, expiresInMs }
|
|
427
|
+
return { options, recommendation, choices, kind: input.kind, urgency, tier, deadlineMs, onTimeout, expiresInMs }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function validChoices(raw: unknown): DecisionChoice[] {
|
|
431
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > CHOICES_MAX) throw invalid(`choices are 1–${CHOICES_MAX} groups`)
|
|
432
|
+
const groups: DecisionChoice[] = []
|
|
433
|
+
for (const group of raw as Record<string, unknown>[]) {
|
|
434
|
+
if (typeof group?.id !== "string" || !CHOICE_ID.test(group.id)) throw invalid("Choice group ids are 1–8 letters, digits, - or _")
|
|
435
|
+
if (groups.some((g) => g.id === group.id)) throw invalid(`Choice group ${group.id} is given twice`)
|
|
436
|
+
const label = cleanLabel(group.label, CHOICE_LABEL_MAX, `Choice group ${group.id}'s label`)
|
|
437
|
+
const list = group.options
|
|
438
|
+
if (!Array.isArray(list) || list.length < CHOICE_OPTIONS_MIN || list.length > CHOICE_OPTIONS_MAX) throw invalid(`Choice group ${group.id} offers ${CHOICE_OPTIONS_MIN}–${CHOICE_OPTIONS_MAX} options`)
|
|
439
|
+
const options: { id: string; label: string; recommended?: true }[] = []
|
|
440
|
+
for (const option of list as Record<string, unknown>[]) {
|
|
441
|
+
if (typeof option?.id !== "string" || !OPTION_ID.test(option.id)) throw invalid("Option ids are 1–32 lowercase letters, digits, - or _")
|
|
442
|
+
if (options.some((o) => o.id === option.id)) throw invalid(`Option ${option.id} is given twice in ${group.id}`)
|
|
443
|
+
if (option.recommended !== undefined && typeof option.recommended !== "boolean") throw invalid("recommended must be true or false")
|
|
444
|
+
options.push({ id: option.id, label: cleanLabel(option.label, OPTION_LABEL_MAX, "An option label"), ...(option.recommended === true ? { recommended: true as const } : {}) })
|
|
445
|
+
}
|
|
446
|
+
if (options.filter((o) => o.recommended).length > 1) throw invalid(`Choice group ${group.id} has more than one recommended option`)
|
|
447
|
+
groups.push({ id: group.id, label, options })
|
|
448
|
+
}
|
|
449
|
+
return groups
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** (DC-1) An answer body's `picks`: undefined when absent, else {group: option} strings (checked against the Decision later). */
|
|
453
|
+
export function answerPicks(raw: unknown): Record<string, string> | undefined {
|
|
454
|
+
if (raw === undefined) return undefined
|
|
455
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || Object.keys(raw).length > CHOICES_MAX) throw invalid("picks must be {group: option}")
|
|
456
|
+
// No prototype: a "__proto__" key stays an own key (an unknown group, refused later) instead of vanishing.
|
|
457
|
+
const out: Record<string, string> = Object.create(null)
|
|
458
|
+
for (const [group, option] of Object.entries(raw)) {
|
|
459
|
+
if (typeof option !== "string") throw invalid("picks must be {group: option}")
|
|
460
|
+
out[group] = option
|
|
461
|
+
}
|
|
462
|
+
return out
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The audit detail of an answer: the option, the picks (validated IDs) or "text". */
|
|
466
|
+
export function answerDetail(answer: DecisionAnswer | undefined): string {
|
|
467
|
+
if (answer?.option !== undefined) return answer.option
|
|
468
|
+
if (answer?.picks) return `picks ${Object.entries(answer.picks).map(([g, o]) => `${g}=${o}`).join(",")}`
|
|
469
|
+
return "text"
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** What the asker reads: the option, the picks ("D1: label [a]" per line) or the text, then the note. */
|
|
473
|
+
function answerText(decision: Decision, choice: { option?: string; text?: string; picks?: Readonly<Record<string, string>> }, by?: string): string {
|
|
474
|
+
const head = `Decision ${decision.id}:${by ? ` ${by}` : ""}`
|
|
475
|
+
if (choice.picks && decision.choices) {
|
|
476
|
+
const lines = decision.choices.map((g) => `${g.id}: ${g.options.find((o) => o.id === choice.picks![g.id])?.label ?? choice.picks![g.id]} [${choice.picks![g.id]}]`)
|
|
477
|
+
return `${head}\n${lines.join("\n")}${choice.text ? `\n${choice.text}` : ""}`
|
|
478
|
+
}
|
|
479
|
+
const option = decision.options.find((o) => o.id === choice.option)
|
|
480
|
+
if (option) return `Decision ${decision.id}: ${option.label} [${option.id}]${by ? ` ${by}` : ""}${choice.text ? `\n${choice.text}` : ""}`
|
|
481
|
+
if (decision.choices) return `Decision ${decision.id}: no picks, text: ${choice.text}${by ? ` ${by}` : ""}`
|
|
482
|
+
return `Decision ${decision.id}: ${choice.text}${by ? ` ${by}` : ""}`
|
|
388
483
|
}
|
|
389
484
|
|
|
390
485
|
export class Decisions {
|
|
@@ -410,6 +505,7 @@ export class Decisions {
|
|
|
410
505
|
if (actor.kind === "user") throw invalid("You are @me: the user cannot ask themselves")
|
|
411
506
|
if (typeof input.question !== "string") throw invalid("question must be a string")
|
|
412
507
|
const v = validate(input)
|
|
508
|
+
if (v.choices && extra.action) throw invalid("A hub action never uses choices")
|
|
413
509
|
const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
|
|
414
510
|
const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
|
|
415
511
|
const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
|
|
@@ -422,6 +518,8 @@ export class Decisions {
|
|
|
422
518
|
question: message.text,
|
|
423
519
|
options: v.options,
|
|
424
520
|
...(v.recommendation ? { recommendation: v.recommendation } : {}),
|
|
521
|
+
...(v.choices ? { choices: v.choices } : {}),
|
|
522
|
+
...(v.kind ? { kind: v.kind } : {}),
|
|
425
523
|
urgency: v.urgency,
|
|
426
524
|
tier: v.tier,
|
|
427
525
|
...(v.deadlineMs === undefined ? {} : { deadline: message.at + v.deadlineMs }),
|
|
@@ -486,9 +584,13 @@ export class Decisions {
|
|
|
486
584
|
const decision = await this.settle(userID, id, answer)
|
|
487
585
|
if (!decision) throw gone()
|
|
488
586
|
const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
|
|
587
|
+
// The answer is settled and any action has completed. An involvement hook must not undo the user's answer.
|
|
588
|
+
if (this.deps.answered) {
|
|
589
|
+
try { await this.deps.answered(userID, decision, by) }
|
|
590
|
+
catch (cause) { console.error("[hub] user Decision involvement failed:", cause) }
|
|
591
|
+
}
|
|
489
592
|
this.emit(userID, decision)
|
|
490
|
-
const
|
|
491
|
-
const said = option ? `Decision ${id}: ${option.label} [${option.id}]${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text}`
|
|
593
|
+
const said = answerText(decision, choice)
|
|
492
594
|
const text = note ? `${said}\n${note}` : said
|
|
493
595
|
return { decision, ...(await this.replyTo(userID, decision, text, by)) }
|
|
494
596
|
}
|
|
@@ -502,7 +604,7 @@ export class Decisions {
|
|
|
502
604
|
async answerByApp(
|
|
503
605
|
userID: string,
|
|
504
606
|
id: string,
|
|
505
|
-
input: { readonly option?: string; readonly text?: string },
|
|
607
|
+
input: { readonly option?: string; readonly text?: string; readonly picks?: Readonly<Record<string, string>> },
|
|
506
608
|
app: AppAnswerer,
|
|
507
609
|
checks: { readonly owned: (decision: Decision) => Promise<boolean>; readonly ready?: (decision: Decision) => void },
|
|
508
610
|
): Promise<{ decision: Decision; reply?: Message }> {
|
|
@@ -519,9 +621,7 @@ export class Decisions {
|
|
|
519
621
|
const decision = await this.settle(userID, id, answer)
|
|
520
622
|
if (!decision) throw gone()
|
|
521
623
|
this.emit(userID, decision)
|
|
522
|
-
const
|
|
523
|
-
const by = `(answered by the app “${label ?? app.party}”)`
|
|
524
|
-
const text = option ? `Decision ${id}: ${option.label} [${option.id}] ${by}${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text} ${by}`
|
|
624
|
+
const text = answerText(decision, choice, `(answered by the app “${label ?? app.party}”)`)
|
|
525
625
|
return { decision, ...(await this.replyTo(userID, decision, text, HUB_USER(userID))) }
|
|
526
626
|
}
|
|
527
627
|
|
|
@@ -616,8 +716,29 @@ export class Decisions {
|
|
|
616
716
|
return decision.state === "open" && this.now() >= decision.expiresAt ? { ...decision, state: "expired" } : decision
|
|
617
717
|
}
|
|
618
718
|
|
|
619
|
-
private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string } {
|
|
719
|
+
private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string; picks?: Readonly<Record<string, string>> } {
|
|
620
720
|
const text = input.text === undefined ? undefined : cleanText(input.text, ANSWER_MAX, "The answer text")
|
|
721
|
+
if (input.picks !== undefined && !decision.choices) throw invalid("This decision has no choices")
|
|
722
|
+
if (decision.choices) {
|
|
723
|
+
if (input.option !== undefined) throw invalid("This decision has choices: answer with picks")
|
|
724
|
+
// Text alone is the fallback of a console or device that knows no picks.
|
|
725
|
+
if (input.picks === undefined) {
|
|
726
|
+
if (text === undefined) throw invalid(`Pick an option for each of ${decision.choices.map((g) => g.id).join(", ")}`)
|
|
727
|
+
return { text }
|
|
728
|
+
}
|
|
729
|
+
const picks = input.picks as unknown
|
|
730
|
+
if (typeof picks !== "object" || picks === null || Array.isArray(picks)) throw invalid("picks must be {group: option}")
|
|
731
|
+
const given = picks as Record<string, unknown>
|
|
732
|
+
for (const key of Object.keys(given)) if (!decision.choices.some((g) => g.id === key)) throw invalid(`No choice group ${sanitizeLabel(key, 16)}`)
|
|
733
|
+
const out: Record<string, string> = {}
|
|
734
|
+
for (const group of decision.choices) {
|
|
735
|
+
const pick = given[group.id]
|
|
736
|
+
if (pick === undefined) throw invalid(`Pick an option for ${group.id}`)
|
|
737
|
+
if (typeof pick !== "string" || !group.options.some((o) => o.id === pick)) throw invalid(`Pick one of ${group.id}'s options: ${group.options.map((o) => o.id).join(", ")}`)
|
|
738
|
+
out[group.id] = pick
|
|
739
|
+
}
|
|
740
|
+
return { picks: out, ...(text === undefined ? {} : { text }) }
|
|
741
|
+
}
|
|
621
742
|
if (!decision.options.length) {
|
|
622
743
|
if (input.option !== undefined) throw invalid("This decision has no options: answer with text")
|
|
623
744
|
if (text === undefined) throw invalid("This decision needs an answer text")
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
// answers, and nothing sent to an app raises a persistent depth.
|
|
49
49
|
import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
|
|
50
50
|
import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
|
|
51
|
+
import type { ItemBudgets } from "../teams/budgets"
|
|
51
52
|
import { isDeliverResult } from "../../protocol"
|
|
52
53
|
import { randomBytes } from "node:crypto"
|
|
53
54
|
import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
|
|
@@ -58,7 +59,7 @@ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, rende
|
|
|
58
59
|
import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
|
|
59
60
|
import { AgentError } from "./errors"
|
|
60
61
|
import { type ExpiredReason, parseNotAfter } from "./not-after"
|
|
61
|
-
import {
|
|
62
|
+
import { HOP_ABSOLUTE_MAX, HOP_CAP, HopTracker, MessageLimiter, type MessageLimits } from "./message-limits"
|
|
62
63
|
import { parseAddress } from "./names"
|
|
63
64
|
import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
|
|
64
65
|
|
|
@@ -115,6 +116,10 @@ export interface InboundPolicy {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
export interface DeliveryDeps {
|
|
119
|
+
/** Item budgets are consulted only for shared-team exchanges, after the existing wake backstop admits. */
|
|
120
|
+
readonly itemBudgets?: Pick<ItemBudgets, "wake" | "current" | "currents" | "setCurrent" | "chargeChild">
|
|
121
|
+
/** Current team/item eligibility, shared by explicit sends and inherited replies. */
|
|
122
|
+
readonly items?: { eligible(userID: string, item: string, sessionIDs: readonly string[]): Promise<{ readonly ok: true; readonly teamID: string; readonly startedAt?: number; readonly parentItem?: string; readonly child?: boolean } | { readonly ok: false; readonly reason: "closed" | "archived" | "unseated"; readonly sessionID?: string; readonly team?: string }> }
|
|
118
123
|
readonly state: StateStore
|
|
119
124
|
readonly counters: Counters
|
|
120
125
|
readonly registry: DeliveryRegistry
|
|
@@ -128,14 +133,21 @@ export interface DeliveryDeps {
|
|
|
128
133
|
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
129
134
|
/** The first over-cap message between two seats of a team, once per persistent chain. */
|
|
130
135
|
readonly teamHopCap?: (userID: string, input: { readonly teamID: string; readonly sender: string; readonly recipient: string; readonly limit: number }) => Promise<void>
|
|
136
|
+
/**
|
|
137
|
+
* (TA2-7) A message tagged with an item between two seats: true when that pair is looping on the item without
|
|
138
|
+
* progress, so the message is delivered without a wake (and charges no wake). Never throws through: a failure wakes.
|
|
139
|
+
*/
|
|
140
|
+
readonly itemLoop?: (userID: string, input: { readonly teamID: string; readonly item: string; readonly from: string; readonly to: string }) => Promise<boolean>
|
|
131
141
|
/** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
|
|
132
142
|
readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
|
|
133
143
|
/** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
|
|
134
144
|
readonly expired?: (userID: string, message: Message) => void
|
|
135
145
|
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
136
146
|
readonly limits?: Partial<MessageLimits>
|
|
137
|
-
/** Configured global agent-only wake hop limit (default 8
|
|
147
|
+
/** Configured global agent-only wake hop limit (default 8; the live ceiling is AgentLimits', the bound HOP_ABSOLUTE_MAX). */
|
|
138
148
|
readonly hopLimit?: number
|
|
149
|
+
/** Latest hub-wide limit, consulted for every send and wake (console settings can change it live). */
|
|
150
|
+
readonly currentHopLimit?: () => number
|
|
139
151
|
/** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
|
|
140
152
|
readonly relayWindowMs?: number
|
|
141
153
|
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
@@ -153,6 +165,8 @@ export interface DeliveryDeps {
|
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
export interface SendOptions {
|
|
168
|
+
/** Explicit team intake item, already authorized by the agent message route. */
|
|
169
|
+
readonly item?: string
|
|
156
170
|
/** false: admit without starting a turn (reported queued). */
|
|
157
171
|
readonly wake?: boolean
|
|
158
172
|
/** Steer into a running turn, like an ask. */
|
|
@@ -179,6 +193,8 @@ export interface Sent {
|
|
|
179
193
|
readonly woke?: boolean
|
|
180
194
|
/** Why it was not (only when woke is false). */
|
|
181
195
|
readonly wakeSkipped?: WakeSkip
|
|
196
|
+
/** Effective hop cap or wake budget, only with the corresponding wakeSkipped reason. */
|
|
197
|
+
readonly wakeLimit?: number
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
/** A reply or decline as sent, with the message it answered. */
|
|
@@ -279,7 +295,7 @@ export class Delivery {
|
|
|
279
295
|
private readonly owner: string
|
|
280
296
|
|
|
281
297
|
constructor(deps: DeliveryDeps) {
|
|
282
|
-
if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit >
|
|
298
|
+
if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_ABSOLUTE_MAX)) throw new Error(`hopLimit must be 1–${HOP_ABSOLUTE_MAX}`)
|
|
283
299
|
this.deps = deps
|
|
284
300
|
const now = (this.now = deps.now ?? Date.now)
|
|
285
301
|
this.timers = deps.timers ?? defaultTimers
|
|
@@ -321,7 +337,9 @@ export class Delivery {
|
|
|
321
337
|
if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
|
|
322
338
|
const from = this.party(userID, actor)
|
|
323
339
|
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
324
|
-
|
|
340
|
+
const picked = this.pick(resolved)
|
|
341
|
+
const itemContext = await this.checkItem(userID, options.item, from, addr, [picked.sessionID])
|
|
342
|
+
return this.sendTo(userID, from, addr, resolved, picked, kind, text, options, itemContext)
|
|
325
343
|
}
|
|
326
344
|
|
|
327
345
|
/** Like send, to every session the address names (each role holder gets its own message). */
|
|
@@ -330,11 +348,22 @@ export class Delivery {
|
|
|
330
348
|
if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
|
|
331
349
|
const from = this.party(userID, actor)
|
|
332
350
|
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
351
|
+
const itemContext = await this.checkItem(userID, options.item, from, addr, resolved.sessions.map((s) => s.sessionID))
|
|
333
352
|
const sent: Sent[] = []
|
|
334
|
-
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
|
|
353
|
+
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options, itemContext))
|
|
335
354
|
return sent
|
|
336
355
|
}
|
|
337
356
|
|
|
357
|
+
private async checkItem(userID: string, item: string | undefined, from: Party, addr: string, recipients: readonly string[]): Promise<{ teamID: string; startedAt?: number; parentItem?: string; child?: boolean } | undefined> {
|
|
358
|
+
if (item === undefined) return undefined
|
|
359
|
+
if (from.kind !== "session" || !this.deps.items) throw new AgentError("invalid", "item applies to messages between team members")
|
|
360
|
+
const result = await this.deps.items.eligible(userID, item, [from.sessionID, ...recipients])
|
|
361
|
+
if (result.ok) return { teamID: result.teamID, ...(result.startedAt !== undefined ? { startedAt: result.startedAt } : {}), ...(result.parentItem ? { parentItem: result.parentItem } : {}), ...(result.child ? { child: true } : {}) }
|
|
362
|
+
if (result.reason === "closed") throw new AgentError("invalid", `item ${item} is closed`)
|
|
363
|
+
if (result.reason === "archived") throw new AgentError("invalid", `team ${result.team ?? "of this item"} is archived`)
|
|
364
|
+
throw new AgentError("invalid", `the sender and every recipient of ${addr} must be seated in item ${item}'s team`)
|
|
365
|
+
}
|
|
366
|
+
|
|
338
367
|
/** A notAfter validated against this engine's clock (parseNotAfter): throws invalid or expired. */
|
|
339
368
|
checkNotAfter(value: unknown): number | undefined {
|
|
340
369
|
return parseNotAfter(value, this.now())
|
|
@@ -355,6 +384,19 @@ export class Delivery {
|
|
|
355
384
|
return this.hops.humanTurn(userID, sessionID)
|
|
356
385
|
}
|
|
357
386
|
|
|
387
|
+
/** A quiet hub notice (never a wake, never a hop) to the team's entry seat; false when the team has none. */
|
|
388
|
+
async notifyTeamEntry(userID: string, teamID: string, text: string): Promise<boolean> {
|
|
389
|
+
const entry = await this.deps.teamHopNotice?.(userID, teamID)
|
|
390
|
+
if (!entry) return false
|
|
391
|
+
const id = this.newID()
|
|
392
|
+
await this.request(userID, entry, {
|
|
393
|
+
op: "deliver", sessionID: entry, messageID: id, wake: false,
|
|
394
|
+
text: `[hub] notice · ${text} · ${id}`,
|
|
395
|
+
meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
|
|
396
|
+
})
|
|
397
|
+
return true
|
|
398
|
+
}
|
|
399
|
+
|
|
358
400
|
async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
|
|
359
401
|
const entry = await this.deps.teamHopNotice?.(userID, teamID)
|
|
360
402
|
if (!entry) return
|
|
@@ -484,7 +526,7 @@ export class Delivery {
|
|
|
484
526
|
return { message }
|
|
485
527
|
}
|
|
486
528
|
|
|
487
|
-
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
|
|
529
|
+
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions, itemContext?: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean }): Promise<Sent> {
|
|
488
530
|
const sessionID = entry.sessionID
|
|
489
531
|
const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
|
|
490
532
|
await this.deps.inbound?.outbound?.(userID, from, kind)
|
|
@@ -493,6 +535,19 @@ export class Delivery {
|
|
|
493
535
|
await this.limiter.admit(userID, from, recipient)
|
|
494
536
|
const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
|
|
495
537
|
const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
|
|
538
|
+
// Try the sender's latest received/assigned item across teams, not just the default shared hop team:
|
|
539
|
+
// a session may be seated in A and B while its current work belongs to B.
|
|
540
|
+
if (!options.item && from.kind === "session" && this.deps.itemBudgets && this.deps.items) {
|
|
541
|
+
for (const candidate of await this.deps.itemBudgets.currents(userID, from.sessionID)) {
|
|
542
|
+
try {
|
|
543
|
+
const eligible = await this.deps.items.eligible(userID, candidate.item, [from.sessionID, sessionID])
|
|
544
|
+
if (!eligible.ok || eligible.teamID !== candidate.teamID) continue
|
|
545
|
+
itemContext = { teamID: eligible.teamID, ...(eligible.startedAt !== undefined ? { startedAt: eligible.startedAt } : {}), ...(eligible.parentItem ? { parentItem: eligible.parentItem } : {}), ...(eligible.child ? { child: true } : {}) }
|
|
546
|
+
options = { ...options, item: candidate.item }
|
|
547
|
+
break
|
|
548
|
+
} catch (cause) { console.error("[hub] sender item context lookup failed:", cause) }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
496
551
|
const message = await this.messages.send(userID, {
|
|
497
552
|
from,
|
|
498
553
|
to: addr,
|
|
@@ -500,6 +555,7 @@ export class Delivery {
|
|
|
500
555
|
kind,
|
|
501
556
|
text,
|
|
502
557
|
hop,
|
|
558
|
+
...(options.item ? { item: options.item } : {}),
|
|
503
559
|
...(generation === undefined ? {} : { generation }),
|
|
504
560
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
505
561
|
...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
|
|
@@ -509,14 +565,20 @@ export class Delivery {
|
|
|
509
565
|
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
|
|
510
566
|
const team = "team" in resolved ? resolved.team : undefined
|
|
511
567
|
const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
|
|
512
|
-
const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID)
|
|
513
|
-
|
|
514
|
-
|
|
568
|
+
const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID ?? itemContext?.teamID)
|
|
569
|
+
if (context && itemContext?.teamID === context.teamID && this.deps.itemBudgets && message.item) {
|
|
570
|
+
await this.deps.itemBudgets.setCurrent(userID, context.teamID, sessionID, message.item)
|
|
571
|
+
.catch((cause) => console.error("[hub] item context write failed:", cause))
|
|
572
|
+
}
|
|
573
|
+
const global = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP
|
|
574
|
+
const limit = Math.min(context?.limit ?? global, global)
|
|
575
|
+
const skip = await this.wakeFor(userID, message, unwanted, limit, context?.teamID, itemContext?.teamID === context?.teamID ? itemContext : undefined)
|
|
576
|
+
const skipped = skip.reason
|
|
515
577
|
if (kind === "ask" && unwanted === undefined && hop <= limit) {
|
|
516
578
|
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
517
579
|
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
518
580
|
}
|
|
519
|
-
return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
|
|
581
|
+
return this.deliver(userID, message, sessionID, skipped, options.urgent === true, skip.wakeLimit ?? (skipped === "hop_cap" ? limit : skipped === "budget" ? this.limiter.wakeLimit : undefined))
|
|
520
582
|
}
|
|
521
583
|
|
|
522
584
|
private schedule(): void {
|
|
@@ -611,7 +673,7 @@ export class Delivery {
|
|
|
611
673
|
replyTo: string,
|
|
612
674
|
options: SendOptions,
|
|
613
675
|
kind: "reply" | "decline",
|
|
614
|
-
store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
|
|
676
|
+
store: (input: { from: Party; replyTo: string; hop: number; generation?: number; item?: string }) => Promise<{ message: Message; original: Message }>,
|
|
615
677
|
): Promise<Replied> {
|
|
616
678
|
const from = this.party(userID, actor)
|
|
617
679
|
const original = await this.messages.get(userID, replyTo)
|
|
@@ -627,25 +689,43 @@ export class Delivery {
|
|
|
627
689
|
if (to) await this.limiter.admit(userID, from, to)
|
|
628
690
|
const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
|
|
629
691
|
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
630
|
-
|
|
692
|
+
let item: string | undefined
|
|
693
|
+
let itemContext: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean } | undefined
|
|
694
|
+
if (original?.item && from.kind === "session" && to?.kind === "session") {
|
|
695
|
+
try {
|
|
696
|
+
const eligible = await this.deps.items?.eligible(userID, original.item, [from.sessionID, to.sessionID])
|
|
697
|
+
if (eligible?.ok) { item = original.item; itemContext = { teamID: eligible.teamID, ...(eligible.startedAt !== undefined ? { startedAt: eligible.startedAt } : {}), ...(eligible.parentItem ? { parentItem: eligible.parentItem } : {}), ...(eligible.child ? { child: true } : {}) } }
|
|
698
|
+
} catch (cause) {
|
|
699
|
+
// A reply must still be delivered; uncertainty about item attribution fails closed (no item).
|
|
700
|
+
console.error("[hub] reply item eligibility failed:", cause)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }), ...(item ? { item } : {}) })
|
|
631
704
|
const { message } = answered
|
|
632
705
|
await this.hops.received(userID, message.recipient, hop, from)
|
|
633
706
|
this.users.add(userID)
|
|
634
707
|
if (message.recipient.kind !== "session") return answered
|
|
635
708
|
const sessionID = message.recipient.sessionID
|
|
636
709
|
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
|
|
637
|
-
const context = await this.deps.teams?.hopContext(userID, from, message.recipient)
|
|
638
|
-
|
|
639
|
-
|
|
710
|
+
const context = await this.deps.teams?.hopContext(userID, from, message.recipient, itemContext?.teamID)
|
|
711
|
+
if (context && itemContext?.teamID === context.teamID && this.deps.itemBudgets && message.item) {
|
|
712
|
+
await this.deps.itemBudgets.setCurrent(userID, context.teamID, sessionID, message.item)
|
|
713
|
+
.catch((cause) => console.error("[hub] item context write failed:", cause))
|
|
714
|
+
}
|
|
715
|
+
const global = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP
|
|
716
|
+
const limit = Math.min(context?.limit ?? global, global)
|
|
717
|
+
const skip = await this.wakeFor(userID, message, unwanted, limit, context?.teamID, itemContext?.teamID === context?.teamID ? itemContext : undefined)
|
|
718
|
+
const skipped = skip.reason
|
|
719
|
+
return { ...(await this.deliver(userID, message, sessionID, skipped, true, skip.wakeLimit ?? (skipped === "hop_cap" ? limit : skipped === "budget" ? this.limiter.wakeLimit : undefined))), original: answered.original }
|
|
640
720
|
}
|
|
641
721
|
|
|
642
722
|
/**
|
|
643
723
|
* Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
|
|
644
724
|
* the hop cap (alerting once at the cap), or no wake budget left.
|
|
645
725
|
*/
|
|
646
|
-
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.hopLimit ?? HOP_CAP, teamID?: string): Promise<WakeSkip
|
|
647
|
-
if (unwanted) return unwanted
|
|
648
|
-
if (message.recipient.kind !== "session") return "wake_false"
|
|
726
|
+
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP, teamID?: string, itemContext?: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean }): Promise<{ reason?: WakeSkip; wakeLimit?: number }> {
|
|
727
|
+
if (unwanted) return { reason: unwanted }
|
|
728
|
+
if (message.recipient.kind !== "session") return { reason: "wake_false" }
|
|
649
729
|
if (message.hop > limit) {
|
|
650
730
|
if (teamID && message.from.kind === "session") {
|
|
651
731
|
try {
|
|
@@ -662,9 +742,41 @@ export class Delivery {
|
|
|
662
742
|
text: `Agent messages went ${limit} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
|
|
663
743
|
})
|
|
664
744
|
}
|
|
665
|
-
return "hop_cap"
|
|
745
|
+
return { reason: "hop_cap" }
|
|
746
|
+
}
|
|
747
|
+
if (teamID && itemContext?.teamID === teamID && message.item && message.from.kind === "session" && this.deps.itemLoop) {
|
|
748
|
+
let looping = false
|
|
749
|
+
try { looping = await this.deps.itemLoop(userID, { teamID, item: message.item, from: message.from.sessionID, to: message.recipient.sessionID }) }
|
|
750
|
+
catch (cause) { console.error("[hub] item loop check failed:", cause) }
|
|
751
|
+
if (looping) return { reason: "item_loop" }
|
|
752
|
+
}
|
|
753
|
+
if (!(await this.limiter.wake(userID, message.recipient))) return { reason: "budget" }
|
|
754
|
+
if (teamID && this.deps.itemBudgets) {
|
|
755
|
+
// A wake is the hub deciding to wake: do not refund for a later spoke failure or offline pending delivery.
|
|
756
|
+
let chargedItem = itemContext && message.item ? (itemContext.child ? itemContext.parentItem : message.item) : undefined
|
|
757
|
+
let chargedStartedAt = chargedItem === message.item ? itemContext?.startedAt : undefined
|
|
758
|
+
if (chargedItem && itemContext?.parentItem) {
|
|
759
|
+
try {
|
|
760
|
+
const root = message.from.kind === "session" && message.recipient.kind === "session" ? await this.deps.items?.eligible(userID, chargedItem, [message.from.sessionID, message.recipient.sessionID]) : undefined
|
|
761
|
+
if (!root?.ok || root.child || root.teamID !== teamID) chargedItem = undefined
|
|
762
|
+
else chargedStartedAt = root.startedAt
|
|
763
|
+
} catch (cause) { console.error("[hub] parent item eligibility failed:", cause); chargedItem = undefined }
|
|
764
|
+
}
|
|
765
|
+
let budget: Awaited<ReturnType<ItemBudgets["wake"]>>
|
|
766
|
+
const seats = message.from.kind === "session" ? [message.from.sessionID, message.recipient.sessionID] : [message.recipient.sessionID]
|
|
767
|
+
try { budget = await this.deps.itemBudgets.wake(userID, teamID, chargedItem, chargedStartedAt, seats) }
|
|
768
|
+
catch (cause) {
|
|
769
|
+
// The message is stored already. A failed budget read/write must never turn it into a send error or a wake.
|
|
770
|
+
console.error("[hub] item budget charge failed; skipping wake:", cause)
|
|
771
|
+
return { reason: "item_budget" }
|
|
772
|
+
}
|
|
773
|
+
if (budget.allowed && itemContext?.child && message.item) {
|
|
774
|
+
try { await this.deps.itemBudgets.chargeChild(userID, teamID, message.item, chargedItem ?? "pool") }
|
|
775
|
+
catch (cause) { console.error("[hub] child item usage failed:", cause) }
|
|
776
|
+
}
|
|
777
|
+
if (!budget.allowed) return { reason: "item_budget", ...(budget.reason === "wakes" ? { wakeLimit: budget.limit } : {}) }
|
|
666
778
|
}
|
|
667
|
-
return
|
|
779
|
+
return {}
|
|
668
780
|
}
|
|
669
781
|
|
|
670
782
|
/** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
|
|
@@ -702,9 +814,9 @@ export class Delivery {
|
|
|
702
814
|
}
|
|
703
815
|
|
|
704
816
|
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
705
|
-
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
|
|
817
|
+
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean, wakeLimit?: number): Promise<Sent> {
|
|
706
818
|
const wake = skipped === undefined
|
|
707
|
-
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
|
|
819
|
+
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped, ...(wakeLimit !== undefined ? { wakeLimit } : {}) }
|
|
708
820
|
// Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
|
|
709
821
|
// one starts then carries them, instead of them arriving inside it.
|
|
710
822
|
if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The /api/agent error envelope (design §8, §14.5): {error: {code, message, hint?, ...extra}}. Only /api/agent/*
|
|
2
|
-
// answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it)
|
|
2
|
+
// answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it), which
|
|
3
|
+
// is rewritten to this one under /api/v1 only (../api-v1.ts).
|
|
3
4
|
|
|
4
5
|
/** HTTP status of each agent error code. */
|
|
5
6
|
export const AGENT_ERROR_STATUS = {
|
|
@@ -244,9 +244,18 @@ export function roleEvent(sessionID: string, held: boolean, role: { role: string
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* (SS-1) Whether a session.changed is a change of state, not of health alone (state === prev). Before health fields,
|
|
249
|
+
* every session.changed was a state change; consumers that mean "went into state X" check this.
|
|
250
|
+
*/
|
|
251
|
+
export const stateChanged = (data: Readonly<Record<string, unknown>> | undefined): boolean => data?.prev === undefined || data.prev !== data.state
|
|
252
|
+
|
|
247
253
|
interface Known {
|
|
248
254
|
state: string
|
|
249
255
|
blockers: Set<string>
|
|
256
|
+
/** (SS-1) The health kind and quota reset last journaled (undefined: ok). */
|
|
257
|
+
healthKind?: string
|
|
258
|
+
healthResetAt?: number
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
/**
|
|
@@ -266,8 +275,17 @@ export class StatusDiff {
|
|
|
266
275
|
const open = new Set(entry.blockers.map((b) => b.id))
|
|
267
276
|
for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
|
|
268
277
|
for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
// (SS-1) Flat health fields (the journal's data is flat): healthKind while not ok and once as "ok" on recovery,
|
|
279
|
+
// healthResetAt for a quota; a change of health alone is a session.changed too. Never the provider's text.
|
|
280
|
+
const health = (entry as { health?: { kind: string; resetAt?: number } }).health
|
|
281
|
+
const healthKind = health && health.kind !== "ok" ? health.kind : undefined
|
|
282
|
+
const healthResetAt = healthKind !== undefined ? health!.resetAt : undefined
|
|
283
|
+
const healthChanged = known?.healthKind !== healthKind || known?.healthResetAt !== healthResetAt
|
|
284
|
+
if (known?.state !== entry.state || healthChanged) {
|
|
285
|
+
const healthData = healthKind !== undefined ? { healthKind, ...(healthResetAt !== undefined ? { healthResetAt } : {}) } : known?.healthKind !== undefined ? { healthKind: "ok" } : {}
|
|
286
|
+
out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}), ...healthData } })
|
|
287
|
+
}
|
|
288
|
+
after.set(entry.id, { state: entry.state, blockers: open, ...(healthKind !== undefined ? { healthKind } : {}), ...(healthResetAt !== undefined ? { healthResetAt } : {}) })
|
|
271
289
|
}
|
|
272
290
|
this.trees.set(rootID, after)
|
|
273
291
|
return out
|
|
@@ -120,6 +120,7 @@ export interface LaunchEngineDeps {
|
|
|
120
120
|
/** This process (pod), as placement lease owner. */
|
|
121
121
|
readonly owner: string
|
|
122
122
|
readonly caps?: HubLaunches
|
|
123
|
+
readonly currentCaps?: () => { readonly maxQueued: number; readonly maxActive: number; readonly perHour: number; readonly lostAfterMinutes?: number | false }
|
|
123
124
|
readonly nodes: EngineNodes
|
|
124
125
|
readonly nodeOps: EngineNodeOps
|
|
125
126
|
readonly registry: EngineRegistry
|
|
@@ -334,7 +335,10 @@ export class LaunchEngine {
|
|
|
334
335
|
private readonly timers: Timers
|
|
335
336
|
private tickTimer: unknown
|
|
336
337
|
/** How long the reporting node instance may omit a running launch (NODE_UNREPORTED_MS, or caps.lostAfterMinutes; undefined: never). */
|
|
337
|
-
private
|
|
338
|
+
private get nodeLostMs(): number | undefined {
|
|
339
|
+
const lostAfter = this.deps.currentCaps?.().lostAfterMinutes ?? this.deps.caps?.lostAfterMinutes
|
|
340
|
+
return lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
|
|
341
|
+
}
|
|
338
342
|
/** Machines being reconciled now. */
|
|
339
343
|
private readonly reconciling = new Set<string>()
|
|
340
344
|
/** Reconcile cleanup retries per launch, and the launches whose failing retry was logged. */
|
|
@@ -352,8 +356,6 @@ export class LaunchEngine {
|
|
|
352
356
|
this.now = deps.now ?? Date.now
|
|
353
357
|
this.timers = deps.timers ?? defaultTimers
|
|
354
358
|
this.store = new LaunchStore({ state: deps.state, now: this.now })
|
|
355
|
-
const lostAfter = deps.caps?.lostAfterMinutes
|
|
356
|
-
this.nodeLostMs = lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
|
|
357
359
|
const { registry } = deps
|
|
358
360
|
this.placement = new Placement({
|
|
359
361
|
store: this.store,
|
|
@@ -369,6 +371,7 @@ export class LaunchEngine {
|
|
|
369
371
|
locks: deps.locks,
|
|
370
372
|
owner: deps.owner,
|
|
371
373
|
...(deps.caps ? { caps: deps.caps } : {}),
|
|
374
|
+
...(deps.currentCaps ? { currentCaps: deps.currentCaps } : {}),
|
|
372
375
|
...(deps.launchers ? { launchers: deps.launchers } : {}),
|
|
373
376
|
now: this.now,
|
|
374
377
|
})
|