@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/registry.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { DoneWatch } from "./done"
|
|
|
19
19
|
import { FrontRelays, RelayRefused } from "./front-relays"
|
|
20
20
|
import { SentenceStream } from "./say-stream"
|
|
21
21
|
import type { ActivityStep, NotificationKind } from "./push"
|
|
22
|
+
import { classifyHealth, type SessionHealth } from "./health/classify"
|
|
22
23
|
|
|
23
24
|
export { ACTIVITY_INTERVAL_MINUTES } from "./store"
|
|
24
25
|
/** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
|
|
@@ -54,8 +55,12 @@ export interface MachineRef {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/** A status tree entry as the hub reports it: working sessions carry when their current run started. */
|
|
57
|
-
/**
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen;
|
|
60
|
+
* (SS-1) `health`: why the session is not making progress (absent: ok). The state itself is unchanged (a retrying
|
|
61
|
+
* session stays "working").
|
|
62
|
+
*/
|
|
63
|
+
export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number; readonly health?: SessionHealth }
|
|
59
64
|
|
|
60
65
|
/** Timer functions (injectable for tests). */
|
|
61
66
|
export interface Timers {
|
|
@@ -590,6 +595,9 @@ export class Registry {
|
|
|
590
595
|
private readonly since = new Map<string, number>()
|
|
591
596
|
/** (Session findability) Blocker ID → when this process first saw it (the "waiting 12m" of Needs you). */
|
|
592
597
|
private readonly blockerSeen = new Map<string, number>()
|
|
598
|
+
/** (SS-1) When each session's current provider retry was first seen, and its health kind with when it began. */
|
|
599
|
+
private readonly retrySeen = new Map<string, number>()
|
|
600
|
+
private readonly healthSeen = new Map<string, { kind: SessionHealth["kind"]; since: number; detail?: string; resetAt?: number }>()
|
|
593
601
|
/** Session → streamed text waiting for its send window. */
|
|
594
602
|
private readonly streams = new Map<string, Stream>()
|
|
595
603
|
/** Machine → its latest terminals frame and the connection that sent it. */
|
|
@@ -1645,10 +1653,33 @@ export class Registry {
|
|
|
1645
1653
|
})
|
|
1646
1654
|
: s.blockers
|
|
1647
1655
|
const entry: LiveStatus = recommended ? { ...s, blockers } : s
|
|
1648
|
-
|
|
1656
|
+
const health = this.health(s)
|
|
1657
|
+
return since === undefined && blockedSince === undefined && health === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}), ...(health ? { health } : {}) }
|
|
1649
1658
|
})
|
|
1650
1659
|
}
|
|
1651
1660
|
|
|
1661
|
+
/** (SS-1) The entry's health with the time its kind began; undefined while ok (the history is then forgotten). */
|
|
1662
|
+
private health(s: SessionStatus): SessionHealth | undefined {
|
|
1663
|
+
const now = this.now()
|
|
1664
|
+
if (s.retry && s.state === "working") {
|
|
1665
|
+
if (!this.retrySeen.has(s.id)) remember(this.retrySeen, s.id, now, BLOCKERS_SEEN)
|
|
1666
|
+
} else this.retrySeen.delete(s.id)
|
|
1667
|
+
const health = classifyHealth(s, { now, ...(this.retrySeen.has(s.id) ? { retrySince: this.retrySeen.get(s.id)! } : {}) })
|
|
1668
|
+
if (health.kind === "ok") {
|
|
1669
|
+
this.healthSeen.delete(s.id)
|
|
1670
|
+
return undefined
|
|
1671
|
+
}
|
|
1672
|
+
let seen = this.healthSeen.get(s.id)
|
|
1673
|
+
if (seen?.kind !== health.kind) remember(this.healthSeen, s.id, (seen = { kind: health.kind, since: now }), BLOCKERS_SEEN)
|
|
1674
|
+
// A relative hint ("reset after 118h") is anchored when its message is first seen: re-parsing it at every read
|
|
1675
|
+
// would move resetAt forward each time (and journal a change for every status update).
|
|
1676
|
+
if (health.resetAt !== undefined) {
|
|
1677
|
+
if (seen!.detail !== health.detail || seen!.resetAt === undefined) Object.assign(seen!, { detail: health.detail, resetAt: health.resetAt })
|
|
1678
|
+
return { ...health, resetAt: seen!.resetAt, since: seen!.since }
|
|
1679
|
+
}
|
|
1680
|
+
return { ...health, since: seen!.since }
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1652
1683
|
catalog(): CatalogMachine[] {
|
|
1653
1684
|
const machines = new Map<string, { id: string; name: string; processes: CatalogProcess[] }>()
|
|
1654
1685
|
for (const conn of this.all().sort((a, b) => a.seq - b.seq)) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Per-team intake wake usage. An item pauses alone when its wakes or running hours are exhausted.
|
|
2
|
+
import type { StateStore } from "../backend/types"
|
|
3
|
+
|
|
4
|
+
const COLLECTION = "team-item-budget"
|
|
5
|
+
const CONTEXT = "team-item-context"
|
|
6
|
+
const EXHAUSTION = "team-item-exhaustion"
|
|
7
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
8
|
+
|
|
9
|
+
export interface ItemBudgetLimits { readonly wakes: number; readonly hours: number }
|
|
10
|
+
export interface ItemBudgetUsage { readonly wakes: number; readonly hours: number; readonly budget: ItemBudgetLimits; readonly generation: number }
|
|
11
|
+
type Usage = { readonly wakes: readonly number[]; readonly generation?: number; readonly startedAt?: number; readonly extendedBy?: readonly string[]; readonly charged?: Readonly<Record<string, number>> }
|
|
12
|
+
/** Sessions charged a wake per item (TA2-8 resets only these); oldest dropped beyond this. */
|
|
13
|
+
const CHARGED_MAX = 32
|
|
14
|
+
type ChildUsage = { readonly wakes: number; readonly chargedTo: string | "pool"; readonly at: number }
|
|
15
|
+
|
|
16
|
+
export class ItemBudgets {
|
|
17
|
+
constructor(private readonly deps: {
|
|
18
|
+
readonly state: StateStore
|
|
19
|
+
readonly limits: (userID: string, teamID: string) => Promise<ItemBudgetLimits> | ItemBudgetLimits
|
|
20
|
+
readonly now?: () => number
|
|
21
|
+
readonly exhausted?: (userID: string, teamID: string, itemID: string | undefined, reason: "wakes" | "hours", limit: number, usage: ItemBudgetUsage) => Promise<void>
|
|
22
|
+
}) {}
|
|
23
|
+
|
|
24
|
+
private now(): number { return (this.deps.now ?? Date.now)() }
|
|
25
|
+
private key(teamID: string, itemID: string | undefined): string { return `${teamID}:${itemID ?? "unattributed"}` }
|
|
26
|
+
private active(row: Usage | undefined, now: number): number[] { return (row?.wakes ?? []).filter((at) => at > now - DAY_MS) }
|
|
27
|
+
|
|
28
|
+
private static charge(previous: Usage["charged"], sessions: readonly string[], now: number): Record<string, number> | undefined {
|
|
29
|
+
if (!sessions.length) return previous ? { ...previous } : undefined
|
|
30
|
+
const next: Record<string, number> = { ...previous }
|
|
31
|
+
for (const sessionID of sessions) { delete next[sessionID]; next[sessionID] = now }
|
|
32
|
+
const kept = Object.entries(next).sort((a, b) => b[1] - a[1]).slice(0, CHARGED_MAX)
|
|
33
|
+
return Object.fromEntries(kept)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Sessions charged a wake for this item within the last `windowMs` (newest first). */
|
|
37
|
+
async charged(userID: string, teamID: string, itemID: string, windowMs: number): Promise<string[]> {
|
|
38
|
+
const row = (await this.deps.state.get<Usage>(userID, COLLECTION, this.key(teamID, itemID)))?.value
|
|
39
|
+
const since = this.now() - windowMs
|
|
40
|
+
return Object.entries(row?.charged ?? {}).filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([sessionID]) => sessionID)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async report(userID: string, teamID: string, itemID: string | undefined, reason: "wakes" | "hours", limit: number, usage: ItemBudgetUsage, now: number): Promise<void> {
|
|
44
|
+
const key = `${this.key(teamID, itemID)}:${reason}:${itemID ? usage.generation : "pool"}`
|
|
45
|
+
const previous = await this.deps.state.get<{ at: number }>(userID, EXHAUSTION, key)
|
|
46
|
+
if (!itemID && previous && now - previous.value.at >= DAY_MS) await this.deps.state.delete(userID, EXHAUSTION, key, { ifVersion: previous.version })
|
|
47
|
+
const claimed = await this.deps.state.put(userID, EXHAUSTION, key, { at: now }, { ifVersion: 0 })
|
|
48
|
+
if (claimed.ok) try { await this.deps.exhausted?.(userID, teamID, itemID, reason, limit, usage) }
|
|
49
|
+
catch (cause) { console.error("[hub] item budget exhaustion notice failed:", cause) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Last item assigned or received in this team; validity is rechecked before using it. */
|
|
53
|
+
async current(userID: string, teamID: string, sessionID: string): Promise<string | undefined> {
|
|
54
|
+
return (await this.deps.state.get<Record<string, { item: string; at: number }>>(userID, CONTEXT, sessionID))?.value[teamID]?.item
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One row per session, not a scan of every team/session context. Eligibility is rechecked before inheritance. */
|
|
58
|
+
async currents(userID: string, sessionID: string): Promise<readonly { teamID: string; item: string }[]> {
|
|
59
|
+
const row = (await this.deps.state.get<Record<string, { item: string; at: number }>>(userID, CONTEXT, sessionID))?.value ?? {}
|
|
60
|
+
return Object.entries(row)
|
|
61
|
+
.sort(([teamA, a], [teamB, b]) => b.at - a.at || teamA.localeCompare(teamB))
|
|
62
|
+
.map(([teamID, value]) => ({ teamID, item: value.item }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async setCurrent(userID: string, teamID: string, sessionID: string, item: string): Promise<void> {
|
|
66
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
67
|
+
const current = await this.deps.state.get<Record<string, { item: string; at: number }>>(userID, CONTEXT, sessionID)
|
|
68
|
+
const result = await this.deps.state.put(userID, CONTEXT, sessionID, { ...current?.value, [teamID]: { item, at: this.now() } }, { ifVersion: current?.version ?? 0 })
|
|
69
|
+
if (result.ok) return
|
|
70
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 1 + Math.floor(Math.random() * 10)))
|
|
71
|
+
}
|
|
72
|
+
throw new Error("Item context write contention")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async chargeChild(userID: string, teamID: string, itemID: string, chargedTo: string | "pool"): Promise<void> {
|
|
76
|
+
const key = `${teamID}:${itemID}`
|
|
77
|
+
for (let i = 0; i < 64; i++) {
|
|
78
|
+
const current = await this.deps.state.get<ChildUsage>(userID, "team-item-child-usage", key)
|
|
79
|
+
const next = await this.deps.state.put(userID, "team-item-child-usage", key, { wakes: (current?.value.wakes ?? 0) + 1, chargedTo, at: this.now() }, { ifVersion: current?.version ?? 0 })
|
|
80
|
+
if (next.ok) return
|
|
81
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 1 + Math.floor(Math.random() * 10)))
|
|
82
|
+
}
|
|
83
|
+
console.error("[hub] item child usage contention; count may lag")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async usage(userID: string, teamID: string, itemID: string, startedAt?: number, parentItem?: string): Promise<ItemBudgetUsage & { readonly chargedTo?: string }> {
|
|
87
|
+
const now = this.now()
|
|
88
|
+
const limits = await this.deps.limits(userID, teamID)
|
|
89
|
+
const row = (await this.deps.state.get<Usage>(userID, COLLECTION, this.key(teamID, itemID)))?.value
|
|
90
|
+
const child = parentItem ? (await this.deps.state.get<ChildUsage>(userID, "team-item-child-usage", `${teamID}:${itemID}`))?.value : undefined
|
|
91
|
+
const generation = row?.generation ?? 1
|
|
92
|
+
return { wakes: parentItem ? child?.wakes ?? 0 : row?.wakes.length ?? 0, hours: (row?.startedAt ?? startedAt) === undefined ? 0 : Math.max(0, (now - (row?.startedAt ?? startedAt)!) / 3_600_000), budget: { wakes: limits.wakes * generation, hours: limits.hours * generation }, generation, ...(parentItem ? { chargedTo: child?.chargedTo ?? parentItem } : {}) }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Extend is cumulative: the allowance grows, but the prior wake/hour usage remains true. */
|
|
96
|
+
async extend(userID: string, teamID: string, itemID: string, ref: string): Promise<ItemBudgetLimits> {
|
|
97
|
+
const key = this.key(teamID, itemID)
|
|
98
|
+
for (let i = 0; i < 32; i++) {
|
|
99
|
+
const current = await this.deps.state.get<Usage>(userID, COLLECTION, key)
|
|
100
|
+
const prior = current?.value
|
|
101
|
+
if (prior?.extendedBy?.includes(ref)) {
|
|
102
|
+
const base = await this.deps.limits(userID, teamID)
|
|
103
|
+
const generation = prior.generation ?? 1
|
|
104
|
+
return { wakes: base.wakes * generation, hours: base.hours * generation }
|
|
105
|
+
}
|
|
106
|
+
const generation = (prior?.generation ?? 1) + 1
|
|
107
|
+
const result = await this.deps.state.put(userID, COLLECTION, key, { wakes: prior?.wakes ?? [], generation, extendedBy: [...(prior?.extendedBy ?? []), ref], ...(prior?.startedAt !== undefined ? { startedAt: prior.startedAt } : {}), ...(prior?.charged ? { charged: prior.charged } : {}) }, { ifVersion: current?.version ?? 0 })
|
|
108
|
+
if (result.ok) { const base = await this.deps.limits(userID, teamID); return { wakes: base.wakes * generation, hours: base.hours * generation } }
|
|
109
|
+
}
|
|
110
|
+
throw new Error("Item budget extend contention")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Reserve a wake under CAS; false means this item (or its team's unattributed pool) is exhausted. */
|
|
114
|
+
/** `sessions`: the sender and recipient this wake is charged for (recorded on the item, never the pool). */
|
|
115
|
+
async wake(userID: string, teamID: string, itemID?: string, startedAt?: number, sessions: readonly string[] = []): Promise<{ allowed: boolean; limit: number; reason?: "wakes" | "hours" }> {
|
|
116
|
+
const limits = await this.deps.limits(userID, teamID)
|
|
117
|
+
const poolLimit = Math.floor(limits.wakes / 5)
|
|
118
|
+
const now = this.now()
|
|
119
|
+
const key = this.key(teamID, itemID)
|
|
120
|
+
for (let i = 0; i < 64; i++) {
|
|
121
|
+
const current = await this.deps.state.get<Usage>(userID, COLLECTION, key)
|
|
122
|
+
const generation = itemID ? (current?.value.generation ?? 1) : 1
|
|
123
|
+
const limit = itemID ? limits.wakes * generation : poolLimit
|
|
124
|
+
const active = itemID ? [...(current?.value.wakes ?? [])] : this.active(current?.value, now)
|
|
125
|
+
const start = current?.value.startedAt ?? startedAt ?? (itemID ? now : undefined)
|
|
126
|
+
if (itemID && start !== undefined && now - start >= limits.hours * generation * 3_600_000) {
|
|
127
|
+
const usage: ItemBudgetUsage = { wakes: active.length, hours: (now - start) / 3_600_000, budget: { wakes: limit, hours: limits.hours * generation }, generation }
|
|
128
|
+
await this.report(userID, teamID, itemID, "hours", usage.budget.hours, usage, now)
|
|
129
|
+
return { allowed: false, limit: usage.budget.hours, reason: "hours" }
|
|
130
|
+
}
|
|
131
|
+
if (active.length >= limit) {
|
|
132
|
+
await this.report(userID, teamID, itemID, "wakes", limit, { wakes: active.length, hours: start === undefined ? 0 : Math.max(0, (now - start) / 3_600_000), budget: { wakes: limit, hours: limits.hours * generation }, generation }, now)
|
|
133
|
+
return { allowed: false, limit, reason: "wakes" }
|
|
134
|
+
}
|
|
135
|
+
const result = await this.deps.state.put(userID, COLLECTION, key, { wakes: [...active, now], generation, ...(current?.value.extendedBy ? { extendedBy: current.value.extendedBy } : {}), ...(start !== undefined ? { startedAt: start } : {}), ...(itemID && (sessions.length || current?.value.charged) ? { charged: ItemBudgets.charge(current?.value.charged, sessions, now) } : {}) }, { ifVersion: current?.version ?? 0 })
|
|
136
|
+
if (result.ok) return { allowed: true, limit }
|
|
137
|
+
// Avoid a lockstep CAS stampede between sessions on the same item.
|
|
138
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(20, 1 + Math.floor(Math.random() * (i + 1)))))
|
|
139
|
+
}
|
|
140
|
+
// A message may already be stored: fail closed (no wake), never turn contention into a send error.
|
|
141
|
+
console.error("[hub] item budget contention; skipping wake")
|
|
142
|
+
return { allowed: false, limit: itemID ? limits.wakes : poolLimit, reason: "wakes" }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -17,11 +17,9 @@ interface Stall {
|
|
|
17
17
|
export class TeamHopStalls {
|
|
18
18
|
constructor(private readonly deps: {
|
|
19
19
|
readonly state: StateStore
|
|
20
|
-
readonly seats: (userID: string, teamID: string) => Promise<readonly string[]>
|
|
21
20
|
readonly open: (userID: string, teamID: string, sender: string, recipient: string, limit: number) => Promise<string>
|
|
22
21
|
readonly notice?: (userID: string, teamID: string, decisionID: string) => Promise<void>
|
|
23
|
-
readonly close?: (userID: string, decisionID: string) => Promise<void>
|
|
24
|
-
readonly reset: (userID: string, sessionID: string) => Promise<void>
|
|
22
|
+
readonly close?: (userID: string, decisionID: string, reason?: string) => Promise<void>
|
|
25
23
|
readonly now?: () => number
|
|
26
24
|
}) {}
|
|
27
25
|
|
|
@@ -55,11 +53,21 @@ export class TeamHopStalls {
|
|
|
55
53
|
return stall as Stall & { readonly decisionID: string }
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
async
|
|
56
|
+
async matches(userID: string, teamID: string, decisionID: string): Promise<void> {
|
|
59
57
|
const entry = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
|
|
60
58
|
if (!entry || entry.value.decisionID !== decisionID) throw new Error("No matching team hop-cap Decision")
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A user has taken part in this team again: clear its stall and close the pending Renew Decision. */
|
|
62
|
+
/** Whether the team has an open hop-cap Decision. */
|
|
63
|
+
async open(userID: string, teamID: string): Promise<boolean> {
|
|
64
|
+
return (await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID))?.value.decisionID !== undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async clearTeam(userID: string, teamID: string, reason = "The user is involved again"): Promise<void> {
|
|
68
|
+
const entry = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
|
|
69
|
+
if (!entry || !(await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: entry.version })).ok) return
|
|
70
|
+
if (entry.value.decisionID) await this.deps.close?.(userID, entry.value.decisionID, reason)
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
async clearForHuman(userID: string, sessionID: string): Promise<void> {
|
|
@@ -83,6 +83,7 @@ import type { Clock, StateStore, Timers } from "../backend/types"
|
|
|
83
83
|
import { assignmentChangedEvent } from "../workspaces/events"
|
|
84
84
|
import type { MembershipStore, SessionPresence, SessionPresenceLookup, StoredAssignment } from "../workspaces/members"
|
|
85
85
|
import type { TeamSeatStore } from "./seats"
|
|
86
|
+
import type { ItemBudgets, ItemBudgetUsage } from "./budgets"
|
|
86
87
|
import type { TeamStaffingService } from "./staffing"
|
|
87
88
|
import type { Team, TeamStore } from "./store"
|
|
88
89
|
|
|
@@ -139,6 +140,9 @@ export const INTAKE_LIMITS: IntakeLimits = {
|
|
|
139
140
|
|
|
140
141
|
/** A TeamIntake as stored and read: the contract record plus additive fields (HWA-36). */
|
|
141
142
|
export interface StoredIntake extends TeamIntake {
|
|
143
|
+
/** Session-submitted child: its root item, when eligible at submit time. Old rows omit it. */
|
|
144
|
+
readonly parentItem?: string
|
|
145
|
+
readonly usage?: ItemBudgetUsage
|
|
142
146
|
readonly priority: IntakePriority
|
|
143
147
|
readonly labels?: readonly string[]
|
|
144
148
|
/** Due time (epoch ms). */
|
|
@@ -158,6 +162,8 @@ export interface StoredIntake extends TeamIntake {
|
|
|
158
162
|
readonly lastError?: string
|
|
159
163
|
readonly updatedAt: number
|
|
160
164
|
readonly routedAt?: number
|
|
165
|
+
/** First active work (routed or in_progress); pauses and blocked time continue counting. */
|
|
166
|
+
readonly startedAt?: number
|
|
161
167
|
readonly closedAt?: number
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -206,6 +212,15 @@ export interface TeamIntakeDeps {
|
|
|
206
212
|
readonly deliver: IntakeDeliver
|
|
207
213
|
/** A session submitter's hop chain (absent: deliveries start their own chain). */
|
|
208
214
|
readonly hopOf?: IntakeHopOf
|
|
215
|
+
/** Called only for a new, persisted intake submitted by the authenticated user. */
|
|
216
|
+
readonly submittedByUser?: (userID: string, teamID: string, intakeID: string, actor: Extract<AgentActor, { kind: "user" }>) => Promise<void>
|
|
217
|
+
readonly budgets?: Pick<ItemBudgets, "usage">
|
|
218
|
+
/** The entry seat received an item after routing; remember its current work context. */
|
|
219
|
+
readonly routed?: (userID: string, teamID: string, intakeID: string, sessionID: string) => Promise<void>
|
|
220
|
+
/** Resolve a session submitter's eligible current root item; undefined charges its child to the team pool. */
|
|
221
|
+
readonly parentOf?: (userID: string, teamID: string, sessionID: string) => Promise<string | undefined>
|
|
222
|
+
/** A real status/revision change of a routed item; attribution uses the assigned seat, not the reporter's words. */
|
|
223
|
+
readonly progressChanged?: (input: { readonly userID: string; readonly teamID: string; readonly item: string; readonly sessionID?: string; readonly revision: number; readonly status: string; readonly actor: AgentActor }) => void | Promise<void>
|
|
209
224
|
readonly events?: Pick<AgentEvents, "append">
|
|
210
225
|
readonly report?: (cause: unknown) => void
|
|
211
226
|
readonly now?: Clock
|
|
@@ -335,6 +350,12 @@ interface Target {
|
|
|
335
350
|
}
|
|
336
351
|
|
|
337
352
|
export class TeamIntakeService {
|
|
353
|
+
private async withUsage(userID: string, row: IntakeRow): Promise<StoredIntake> {
|
|
354
|
+
const item = view(row)
|
|
355
|
+
if (!this.deps.budgets) return item
|
|
356
|
+
try { return { ...item, usage: await this.deps.budgets.usage(userID, row.teamID, row.id, row.startedAt, row.source.kind === "session" ? row.parentItem ?? "pool" : undefined) } }
|
|
357
|
+
catch (cause) { this.report(cause); return item }
|
|
358
|
+
}
|
|
338
359
|
private readonly state: StateStore
|
|
339
360
|
private readonly now: Clock
|
|
340
361
|
private readonly newID: (kind: IdKind) => string
|
|
@@ -376,6 +397,7 @@ export class TeamIntakeService {
|
|
|
376
397
|
const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
|
|
377
398
|
const id = this.newID("intake")
|
|
378
399
|
const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
|
|
400
|
+
const parentItem = actor.kind === "session" ? await this.deps.parentOf?.(userID, teamID, actor.sessionID) : undefined
|
|
379
401
|
if (body.brief !== undefined && !expired) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
|
|
380
402
|
let result: { row: IntakeRow; created: boolean } | undefined
|
|
381
403
|
try {
|
|
@@ -401,7 +423,7 @@ export class TeamIntakeService {
|
|
|
401
423
|
if (doc.items.filter((i) => !CLOSED.has(i.status)).length >= this.limits.openPerTeam)
|
|
402
424
|
throw new AgentError("rate_limited", `A team holds at most ${this.limits.openPerTeam} open submissions`, { hint: "Close or cancel some first", extra: { limit: this.limits.openPerTeam } })
|
|
403
425
|
const { brief: _brief, ...fields } = body
|
|
404
|
-
const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }) }
|
|
426
|
+
const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }), ...(parentItem ? { parentItem } : {}) }
|
|
405
427
|
result = { row, created: true }
|
|
406
428
|
const nextKeys = slot === undefined ? keys : { ...keys, [slot]: { intakeID: id, digest, at } }
|
|
407
429
|
return { ...doc, items: this.retained([...doc.items, row]), keys: this.boundedKeys(nextKeys) }
|
|
@@ -416,6 +438,10 @@ export class TeamIntakeService {
|
|
|
416
438
|
return { intake: view(await this.reconciled(userID, (await this.row(userID, teamID, result!.row.id)) ?? result!.row)), created: false }
|
|
417
439
|
}
|
|
418
440
|
await this.emit(userID, intakeEvent(result!.row))
|
|
441
|
+
if (actor.kind === "user") {
|
|
442
|
+
try { await this.deps.submittedByUser?.(userID, teamID, id, actor) }
|
|
443
|
+
catch (cause) { this.report(cause) }
|
|
444
|
+
}
|
|
419
445
|
if (result!.row.notAfter !== undefined) this.arm(result!.row.notAfter)
|
|
420
446
|
await this.drainSafely(userID, teamID)
|
|
421
447
|
return { intake: view((await this.row(userID, teamID, id)) ?? result!.row), created: true }
|
|
@@ -429,7 +455,8 @@ export class TeamIntakeService {
|
|
|
429
455
|
await this.drainSafely(userID, teamID)
|
|
430
456
|
const row = await this.row(userID, teamID, intakeID)
|
|
431
457
|
if (!row || (scope === "own" && !sameSource(row, actor))) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
|
|
432
|
-
|
|
458
|
+
const current = await this.reconciled(userID, row)
|
|
459
|
+
return this.withUsage(userID, current)
|
|
433
460
|
}
|
|
434
461
|
|
|
435
462
|
/** The team's intake the caller may see, oldest first, paged. */
|
|
@@ -448,7 +475,7 @@ export class TeamIntakeService {
|
|
|
448
475
|
.filter((i) => query.status === undefined || i.status === query.status)
|
|
449
476
|
.filter((i) => !after || i.createdAt > after[0] || (i.createdAt === after[0] && i.id > after[1]))
|
|
450
477
|
.sort(byCreation)
|
|
451
|
-
const items = all.slice(0, limit).map(
|
|
478
|
+
const items = await Promise.all(all.slice(0, limit).map((row) => this.withUsage(userID, row)))
|
|
452
479
|
return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!) } : { items }
|
|
453
480
|
}
|
|
454
481
|
|
|
@@ -517,9 +544,13 @@ export class TeamIntakeService {
|
|
|
517
544
|
const { progress: _p, claim: _c, ...rest } = current
|
|
518
545
|
if (closing) return { ...rest, status: wanted, ...(reason !== undefined ? { reason: reason as string } : {}), closedAt: this.now() }
|
|
519
546
|
if (current.progress === wanted) return undefined
|
|
520
|
-
return { ...rest, ...(current.claim ? { claim: current.claim } : {}), progress: wanted as IntakeProgress }
|
|
547
|
+
return { ...rest, ...(current.claim ? { claim: current.claim } : {}), progress: wanted as IntakeProgress, ...(wanted === "in_progress" ? { startedAt: current.startedAt ?? this.now() } : {}) }
|
|
521
548
|
})
|
|
522
|
-
if (next.revision !== row.revision)
|
|
549
|
+
if (next.revision !== row.revision) {
|
|
550
|
+
await this.emit(userID, intakeEvent(next))
|
|
551
|
+
try { await this.deps.progressChanged?.({ userID, teamID, item: intakeID, ...(next.sessionID ? { sessionID: next.sessionID } : {}), revision: next.revision, status: next.progress ?? next.status, actor }) }
|
|
552
|
+
catch (cause) { this.report(cause) }
|
|
553
|
+
}
|
|
523
554
|
if (closing && row.status === "queued") await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
|
|
524
555
|
return { intake: view(next), ...(assignment ? { assignment } : {}) }
|
|
525
556
|
}
|
|
@@ -738,7 +769,7 @@ export class TeamIntakeService {
|
|
|
738
769
|
return claim ? { ...doc, items: doc.items.map((i) => (i.id === intakeID ? rest : i)) } : undefined
|
|
739
770
|
}
|
|
740
771
|
const t = this.now()
|
|
741
|
-
routed = { ...rest, status: "routed", progress: "open", assignmentID: assignment.id, workspaceID: target.workspaceID, sessionID: target.sessionID, messageID: sent.messageID, routedAt: t, revision: row.revision + 1, updatedAt: t }
|
|
772
|
+
routed = { ...rest, status: "routed", progress: "open", assignmentID: assignment.id, workspaceID: target.workspaceID, sessionID: target.sessionID, messageID: sent.messageID, routedAt: t, startedAt: row.startedAt ?? t, revision: row.revision + 1, updatedAt: t }
|
|
742
773
|
return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? routed! : i)) }
|
|
743
774
|
})
|
|
744
775
|
if (cancelled) {
|
|
@@ -747,6 +778,7 @@ export class TeamIntakeService {
|
|
|
747
778
|
}
|
|
748
779
|
await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
|
|
749
780
|
await this.emit(userID, intakeEvent(routed!))
|
|
781
|
+
try { await this.deps.routed?.(userID, team.id, routed!.id, target.sessionID) } catch (cause) { this.report(cause) }
|
|
750
782
|
return "routed"
|
|
751
783
|
} catch (cause) {
|
|
752
784
|
const code = cause instanceof AgentError ? cause.code : "internal"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A user act toward one team (or a current seat of several teams) resets the team's agent-only hop chain.
|
|
2
|
+
import type { TeamHopStalls } from "./hop-stalls"
|
|
3
|
+
|
|
4
|
+
export type InvolvementAct = {
|
|
5
|
+
readonly kind: "decision" | "intake" | "permission" | "form" | "prompt" | "renew" | "manual" | "budget" | "progress"
|
|
6
|
+
readonly teamIDs?: readonly string[]
|
|
7
|
+
readonly sessionID?: string
|
|
8
|
+
readonly ref: string
|
|
9
|
+
readonly actor?: { readonly deviceID?: string; readonly via?: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TeamResetRecord {
|
|
13
|
+
readonly userID: string
|
|
14
|
+
readonly teamID: string
|
|
15
|
+
readonly kind: InvolvementAct["kind"]
|
|
16
|
+
readonly ref: string
|
|
17
|
+
readonly seats: readonly string[]
|
|
18
|
+
readonly failed: readonly string[]
|
|
19
|
+
readonly actor?: InvolvementAct["actor"]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TeamInvolvementDeps {
|
|
23
|
+
/** Current seats only: a departed member must never be reset. */
|
|
24
|
+
readonly seats: (userID: string, teamID: string) => Promise<readonly string[]>
|
|
25
|
+
readonly seatsOf: (userID: string, sessionID: string) => Promise<readonly { readonly teamID: string }[]>
|
|
26
|
+
readonly reset: (userID: string, sessionID: string) => Promise<void>
|
|
27
|
+
readonly stalls: Pick<TeamHopStalls, "clearTeam" | "matches">
|
|
28
|
+
readonly audit: (entry: TeamResetRecord) => Promise<void>
|
|
29
|
+
readonly event: (entry: TeamResetRecord) => Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class TeamInvolvement {
|
|
33
|
+
constructor(private readonly deps: TeamInvolvementDeps) {}
|
|
34
|
+
|
|
35
|
+
/** Renew must fail if lookup or reset fails, leaving its Decision open for a retry. */
|
|
36
|
+
async renew(userID: string, teamID: string, decisionID: string, actor?: InvolvementAct["actor"]): Promise<void> {
|
|
37
|
+
await this.deps.stalls.matches(userID, teamID, decisionID)
|
|
38
|
+
await this.resetTeam(userID, teamID, { kind: "renew", teamIDs: [teamID], ref: decisionID, ...(actor ? { actor } : {}) }, true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The user's Renew button in the console: strict like renew, but needs no stall (a failure → the caller's 500, retryable). */
|
|
42
|
+
async renewManual(userID: string, teamID: string, actor: NonNullable<InvolvementAct["actor"]>): Promise<void> {
|
|
43
|
+
await this.resetTeam(userID, teamID, { kind: "manual", teamIDs: [teamID], ref: "console", actor }, true)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Best effort: never make the user's original action fail because one reset/audit/event did. */
|
|
47
|
+
async involved(userID: string, act: InvolvementAct): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
const teams = new Set(act.teamIDs ?? [])
|
|
50
|
+
if (act.sessionID) for (const seat of await this.deps.seatsOf(userID, act.sessionID)) teams.add(seat.teamID)
|
|
51
|
+
for (const teamID of teams) {
|
|
52
|
+
try { await this.resetTeam(userID, teamID, act, false) }
|
|
53
|
+
catch (cause) { console.error(`[hub] team involvement for ${teamID} failed:`, cause) }
|
|
54
|
+
}
|
|
55
|
+
} catch (cause) { console.error("[hub] team involvement lookup failed:", cause) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A qualifying hub-observed progress signal (TA2-5) resets only seats charged to this item; never all team seats. */
|
|
59
|
+
async progress(userID: string, teamID: string, item: string, charged: readonly string[]): Promise<void> {
|
|
60
|
+
const seats = [...new Set(charged)]
|
|
61
|
+
if (seats.length === 0) return
|
|
62
|
+
const failed: string[] = []
|
|
63
|
+
for (const sessionID of seats) {
|
|
64
|
+
try { await this.deps.reset(userID, sessionID) }
|
|
65
|
+
catch (cause) { failed.push(sessionID); console.error(`[hub] item progress hop reset failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
|
|
66
|
+
}
|
|
67
|
+
// Keep the team-level Renew stall and all seats outside the item's charged set unchanged.
|
|
68
|
+
const record: TeamResetRecord = { userID, teamID, kind: "progress", ref: item, seats, failed, actor: { via: "hub" } }
|
|
69
|
+
try { await this.deps.audit(record) }
|
|
70
|
+
catch (cause) { console.error(`[hub] item progress audit failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
|
|
71
|
+
try { await this.deps.event(record) }
|
|
72
|
+
catch (cause) { console.error(`[hub] item progress event failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async resetTeam(userID: string, teamID: string, act: InvolvementAct, strict: boolean): Promise<void> {
|
|
76
|
+
const seats = [...new Set(await this.deps.seats(userID, teamID))]
|
|
77
|
+
const failed: string[] = []
|
|
78
|
+
for (const sessionID of seats) {
|
|
79
|
+
try { await this.deps.reset(userID, sessionID) }
|
|
80
|
+
catch (cause) {
|
|
81
|
+
failed.push(sessionID)
|
|
82
|
+
console.error(`[hub] team hop reset for ${sessionID} failed:`, cause)
|
|
83
|
+
if (strict) throw cause
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try { await this.deps.stalls.clearTeam(userID, teamID, act.kind === "manual" ? "The user renewed the team from the console" : undefined) }
|
|
87
|
+
catch (cause) { console.error(`[hub] team hop stall clear failed:`, cause); if (strict) throw cause }
|
|
88
|
+
const record: TeamResetRecord = { userID, teamID, kind: act.kind, ref: act.ref, seats, failed, ...(act.actor ? { actor: act.actor } : {}) }
|
|
89
|
+
// Renewal has already cleared its stall: audit/event failure must not reopen a Decision that cannot match.
|
|
90
|
+
try { await this.deps.audit(record) }
|
|
91
|
+
catch (cause) { console.error(`[hub] team hop reset audit failed:`, cause) }
|
|
92
|
+
try { await this.deps.event(record) }
|
|
93
|
+
catch (cause) { console.error(`[hub] team hop reset event failed:`, cause) }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Pure ports for a tap-only item stall Decision. The hub wires these to budgets, loops, intake and involvement.
|
|
2
|
+
/** The value returned by ItemBudgets.extend once TA2-4 is in the parent branch. */
|
|
3
|
+
type ItemBudgetLimits = { readonly wakes: number; readonly hours: number }
|
|
4
|
+
|
|
5
|
+
export interface ItemDecisionStall {
|
|
6
|
+
readonly userID: string
|
|
7
|
+
readonly teamID: string
|
|
8
|
+
readonly item?: string
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly reason: "budget" | "loop"
|
|
11
|
+
readonly decisionID: string
|
|
12
|
+
readonly pair?: readonly [string, string]
|
|
13
|
+
readonly usage: { readonly wakes: number; readonly hours: number }
|
|
14
|
+
readonly limit: ItemBudgetLimits
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ItemDecisionActor { readonly kind: string; readonly userID: string; readonly deviceID?: string; readonly via?: string }
|
|
18
|
+
|
|
19
|
+
export function itemDecisionQuestion(stall: ItemDecisionStall): string | undefined {
|
|
20
|
+
if (!stall.item) return undefined // The team's unattributed pool receives a quiet notice, never a Decision.
|
|
21
|
+
const pair = stall.pair ? ` between ${stall.pair[0]} and ${stall.pair[1]}` : ""
|
|
22
|
+
const title = stall.title.replace(/[\x00-\x1f\x7f\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 120).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
23
|
+
const hours = (value: number) => (Math.round(value * 10) / 10).toFixed(1)
|
|
24
|
+
return `Item ${stall.item} ("${title}") ${stall.reason === "budget" ? "spent its budget" : `is looping${pair}`}. ${stall.usage.wakes}/${stall.limit.wakes} wakes, ${hours(stall.usage.hours)}/${hours(stall.limit.hours)} hours. Extend or Stop?`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ItemDecisions {
|
|
28
|
+
constructor(private readonly deps: {
|
|
29
|
+
readonly matches: (userID: string, teamID: string, itemID: string, decisionID: string) => Promise<boolean>
|
|
30
|
+
readonly clearStall: (userID: string, itemID: string, decisionID: string) => Promise<void>
|
|
31
|
+
readonly extend: (userID: string, teamID: string, itemID: string, decisionID: string) => Promise<ItemBudgetLimits>
|
|
32
|
+
readonly clearLoop: (userID: string, itemID: string, pair: readonly [string, string]) => Promise<void>
|
|
33
|
+
readonly involved: (userID: string, act: { kind: "budget"; teamIDs: string[]; ref: string; actor: { deviceID: string; via: "cookie" } }) => Promise<void>
|
|
34
|
+
readonly stop: (userID: string, teamID: string, itemID: string, reason: "budget" | "loop") => Promise<void>
|
|
35
|
+
readonly audit: (entry: { userID: string; actor: { kind: "user" }; deviceID: string; verb: "team.item.extend" | "team.item.stop"; target: {}; reason: string; decision: "allow"; outcome: "ok" }) => Promise<void>
|
|
36
|
+
}) {}
|
|
37
|
+
|
|
38
|
+
async answer(stall: ItemDecisionStall, option: string | undefined, by: ItemDecisionActor): Promise<"gone" | "done" | { outcome: "done"; note: string }> {
|
|
39
|
+
const { userID, teamID, item, decisionID, reason, pair } = stall
|
|
40
|
+
if (!item || (option !== "extend" && option !== "stop") || by.kind !== "user" || by.userID !== userID || by.via !== "cookie" || !by.deviceID) return "gone"
|
|
41
|
+
if (!(await this.deps.matches(userID, teamID, item, decisionID))) return "gone"
|
|
42
|
+
let note: string | undefined
|
|
43
|
+
if (option === "extend") {
|
|
44
|
+
if (reason === "budget") {
|
|
45
|
+
const limits = await this.deps.extend(userID, teamID, item, decisionID)
|
|
46
|
+
note = `Budget extended to ${limits.wakes} wakes / ${limits.hours} hours`
|
|
47
|
+
} else {
|
|
48
|
+
if (!pair) return "gone"
|
|
49
|
+
await this.deps.clearLoop(userID, item, pair)
|
|
50
|
+
}
|
|
51
|
+
try { await this.deps.involved(userID, { kind: "budget", teamIDs: [teamID], ref: decisionID, actor: { deviceID: by.deviceID, via: "cookie" } }) }
|
|
52
|
+
catch (cause) { console.error("[hub] item Decision involvement failed:", cause) }
|
|
53
|
+
} else await this.deps.stop(userID, teamID, item, reason)
|
|
54
|
+
try { await this.deps.audit({ userID, actor: { kind: "user" }, deviceID: by.deviceID, verb: option === "extend" ? "team.item.extend" : "team.item.stop", target: {}, reason: `${item} ${reason}${pair ? ` ${pair.join(" → ")}` : ""}: limit ${stall.limit.wakes}/${stall.limit.hours}`, decision: "allow", outcome: "ok" }) }
|
|
55
|
+
catch (cause) { console.error("[hub] item Decision audit failed:", cause) }
|
|
56
|
+
try { await this.deps.clearStall(userID, item, decisionID) }
|
|
57
|
+
catch (cause) { console.error("[hub] item Decision stall clear failed:", cause) }
|
|
58
|
+
return note ? { outcome: "done", note } : "done"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// One owner-side item check for explicit attribution and future inherited message context.
|
|
2
|
+
import type { TeamSeatStore } from "./seats"
|
|
3
|
+
import type { TeamStore } from "./store"
|
|
4
|
+
import type { TeamIntakeService } from "./intake"
|
|
5
|
+
|
|
6
|
+
export type ItemEligibility = { 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 }
|
|
7
|
+
|
|
8
|
+
export function itemEligibility(deps: {
|
|
9
|
+
readonly seats: Pick<TeamSeatStore, "seatsOf">
|
|
10
|
+
readonly teams: Pick<TeamStore, "get">
|
|
11
|
+
readonly intake: Pick<TeamIntakeService, "get">
|
|
12
|
+
}): { eligible(userID: string, item: string, sessionIDs: readonly string[]): Promise<ItemEligibility> } {
|
|
13
|
+
return {
|
|
14
|
+
async eligible(userID, itemID, sessionIDs) {
|
|
15
|
+
if (!sessionIDs.length) return { ok: false, reason: "unseated" }
|
|
16
|
+
const seats = await Promise.all(sessionIDs.map((id) => deps.seats.seatsOf(userID, id)))
|
|
17
|
+
for (const seat of seats[0] ?? []) {
|
|
18
|
+
const team = await deps.teams.get(userID, seat.teamID)
|
|
19
|
+
if (!team) continue
|
|
20
|
+
let item
|
|
21
|
+
try {
|
|
22
|
+
item = await deps.intake.get(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, team.id, itemID)
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "not_found") continue
|
|
25
|
+
throw cause
|
|
26
|
+
}
|
|
27
|
+
if (team.state === "archived") return { ok: false, reason: "archived", team: team.name }
|
|
28
|
+
if (item.status === "done" || item.status === "failed" || item.status === "cancelled") return { ok: false, reason: "closed" }
|
|
29
|
+
for (let i = 1; i < sessionIDs.length; i++) if (!seats[i]?.some((s) => s.teamID === team.id)) return { ok: false, reason: "unseated", sessionID: sessionIDs[i] }
|
|
30
|
+
return { ok: true, teamID: team.id, ...(item.startedAt !== undefined ? { startedAt: item.startedAt } : {}), ...(item.parentItem ? { parentItem: item.parentItem } : {}), ...(item.source.kind === "session" ? { child: true } : {}) }
|
|
31
|
+
}
|
|
32
|
+
return { ok: false, reason: "unseated", sessionID: sessionIDs[0] }
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|