@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,667 @@
1
+ // Persistent team definitions (HWA-33; contract packages/protocol/src/teams.ts and docs/plans/2026-10-30-hub-
2
+ // workspaces-artifacts-contracts.md, Teams + clarifications; decisions zltiwb4s, 5etngvgm, r3vv4eon, qxzrdc6c).
3
+ //
4
+ // A team is a versioned definition (roles, one entry role, open/restricted communication with directed edges,
5
+ // tags/metadata/projects), independent of any running session. It grants no authority.
6
+ //
7
+ // Storage: StateStore collections under the hub user (the tenant key; another user's records are absent), so the
8
+ // standalone file store and the cluster's Postgres hub_state behave alike and need no migration:
9
+ // - `teams` (key team ID): the record — the published spec, state (active/archived), `paused`, the published
10
+ // `version`, the retained version history (number, digest, publishedAt) and at most one pending `draft`.
11
+ // `revision` is the StateStore version of the key: every write is version-checked (ifVersion), so a stale
12
+ // expectedRevision is 409 conflict with currentRevision, and of two writers holding one revision exactly one wins.
13
+ // - `team-version.<team ID>` (key `<n>`): one immutable published revision each ({spec, digest, publishedAt}).
14
+ // Slot n is written only by the writer whose record CAS made n the published version (or rebuilt from the record
15
+ // while n is still current, after a crash between the two writes), so a slot never holds another writer's spec.
16
+ // - `team-index` (key `names`): {name → {id, at, archived?}} — unique names per user (the `#team` address) and the
17
+ // cap on active teams (TEAM_LIMITS.teamsPerUser, 429). A reservation whose record never appeared is reclaimed
18
+ // after reservationMs.
19
+ // - `team-idempotency` (key: Idempotency-Key): {teamID, digest, at}, valid IDEMPOTENCY_TTL_MS.
20
+ // - `team-settings` (key `owner`): the owner's `teamIsolation` setting (default off; HWA-40 reads it).
21
+ //
22
+ // Edits are drafts: saveDraft builds on the pending draft (or the published spec) and publish freezes it as the next
23
+ // version; the published spec is what delivery (HWA-34) and staffing (HWA-35) use. Retention keeps the newest
24
+ // versionsRetained versions plus any a caller pins (pinnedVersions, e.g. staffing referencing a revision).
25
+ // Pause and archive are operational states and change no definition. Archived teams keep their name reserved and
26
+ // do not count toward the cap; restore re-checks it.
27
+ //
28
+ // Records from other builds: a newer `v` is never guessed at (lists skip it; reads and writes answer 422
29
+ // unsupported); a malformed record is skipped by lists and a 500 on read. Older hubs never read these collections.
30
+ import { randomBytes } from "node:crypto"
31
+ import {
32
+ CONTRACT_VERSION,
33
+ ID_PREFIXES,
34
+ IDEMPOTENCY_KEY,
35
+ IDEMPOTENCY_TTL_MS,
36
+ type IdKind,
37
+ isExpectedRevision,
38
+ isLabel,
39
+ LIST_LIMIT_DEFAULT,
40
+ LIST_LIMIT_MAX,
41
+ metadataProblem,
42
+ type Page,
43
+ RECORD_STATES,
44
+ type RecordState,
45
+ TEAM_LIMITS,
46
+ type TeamDefinition,
47
+ tagsProblem,
48
+ } from "../../protocol"
49
+ import { AgentError } from "../agent/errors"
50
+ import { updateVersioned } from "../agent/versioned"
51
+ import type { Clock, StateStore } from "../backend/types"
52
+ import { currentSeats, readSeatsDoc, TEAM_SEATS } from "./collections"
53
+ import { diffTeamSpecs, specDigest, TEAM_STORE_LIMITS, type TeamDiff, type TeamRoleSpec, type TeamSpec, teamPatchOf, teamSpecOf } from "./validation"
54
+
55
+ export const TEAMS = "teams"
56
+ export const TEAM_INDEX = "team-index"
57
+ export const TEAM_IDEMPOTENCY = "team-idempotency"
58
+ export const TEAM_SETTINGS = "team-settings"
59
+ export const teamVersionCollection = (teamID: string): string => `team-version.${teamID}`
60
+ const NAMES = "names"
61
+ const OWNER = "owner"
62
+ const ATTEMPTS = 16
63
+ /** Reads of an idempotency key's team: its creator may be between the claim and the write. */
64
+ const READS = 5
65
+
66
+ export interface TeamStoreLimits {
67
+ readonly teamsPerUser: number
68
+ readonly versionsRetained: number
69
+ /** A reservation (name, idempotency key) whose team never got written blocks this long. */
70
+ readonly reservationMs: number
71
+ }
72
+
73
+ /** One retained published revision as history shows it. */
74
+ export interface TeamVersionEntry {
75
+ readonly version: number
76
+ readonly digest: string
77
+ readonly publishedAt: number
78
+ }
79
+
80
+ /** An immutable published revision. */
81
+ export interface TeamVersion {
82
+ readonly v: 1
83
+ readonly teamID: string
84
+ readonly version: number
85
+ readonly spec: TeamSpec
86
+ readonly digest: string
87
+ readonly publishedAt: number
88
+ }
89
+
90
+ /** Pending edits (published on explicit publish). */
91
+ export interface TeamDraft {
92
+ readonly baseVersion: number
93
+ readonly spec: TeamSpec
94
+ readonly digest: string
95
+ readonly savedAt: number
96
+ }
97
+
98
+ /** A team as reads show it: the contract TeamDefinition of the published spec plus the additive fields. */
99
+ export interface Team extends TeamDefinition {
100
+ readonly roles: readonly TeamRoleSpec[]
101
+ readonly projects?: readonly string[]
102
+ /** The published revision number (1 on create, +1 per publish). */
103
+ readonly version: number
104
+ /** Present (true) while paused: intake and new staffing stop; policy still applies. */
105
+ readonly paused?: true
106
+ readonly draft?: Omit<TeamDraft, "spec">
107
+ readonly createdAt: number
108
+ readonly publishedAt: number
109
+ }
110
+
111
+ export interface TeamListQuery {
112
+ /** active (default), archived or all. */
113
+ readonly state?: string
114
+ /** Teams carrying every one of these tags. */
115
+ readonly tags?: readonly string[]
116
+ readonly project?: string
117
+ /** Teams whose metadata has every one of these key/value pairs. */
118
+ readonly metadata?: Readonly<Record<string, string>>
119
+ readonly paused?: boolean
120
+ readonly limit?: number
121
+ readonly cursor?: string
122
+ }
123
+
124
+ export interface TeamSettings {
125
+ readonly teamIsolation: boolean
126
+ readonly revision: number
127
+ readonly updatedAt?: number
128
+ }
129
+
130
+ /** Versions a caller still references (never dropped by retention). */
131
+ export type PinnedVersions = (userID: string, teamID: string) => Promise<ReadonlySet<number>>
132
+
133
+ export interface TeamStoreDeps {
134
+ readonly state: StateStore
135
+ readonly now?: Clock
136
+ readonly newID?: (kind: IdKind) => string
137
+ readonly limits?: Partial<TeamStoreLimits>
138
+ readonly pinnedVersions?: PinnedVersions
139
+ }
140
+
141
+ interface TeamRecord {
142
+ readonly v: 1
143
+ readonly id: string
144
+ readonly spec: TeamSpec
145
+ readonly state: RecordState
146
+ readonly paused?: true
147
+ readonly version: number
148
+ readonly history: readonly TeamVersionEntry[]
149
+ readonly draft?: TeamDraft
150
+ readonly createdAt: number
151
+ readonly publishedAt: number
152
+ readonly updatedAt: number
153
+ }
154
+
155
+ interface NameEntry {
156
+ readonly id: string
157
+ readonly at: number
158
+ readonly archived?: true
159
+ }
160
+ interface NameIndex {
161
+ readonly v: 1
162
+ readonly names: Readonly<Record<string, NameEntry>>
163
+ }
164
+ interface IdempotencyDoc {
165
+ readonly v: 1
166
+ readonly teamID: string
167
+ readonly digest: string
168
+ readonly at: number
169
+ }
170
+ interface SettingsDoc {
171
+ readonly v: 1
172
+ readonly teamIsolation: boolean
173
+ readonly updatedAt: number
174
+ }
175
+
176
+ type Readout = { readonly ok: true; readonly record: TeamRecord } | { readonly ok: false; readonly why: "newer" | "malformed"; readonly v?: unknown }
177
+
178
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
179
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
180
+
181
+ const conflict = (what: string, currentRevision: number) =>
182
+ new AgentError("conflict", `${what} changed since you read it (revision ${currentRevision} now)`, {
183
+ hint: "Read it again and retry with its current revision",
184
+ extra: { currentRevision },
185
+ })
186
+
187
+ const notFound = (id: string) => new AgentError("not_found", `No team ${id}`)
188
+
189
+ function checkExpected(v: unknown): number {
190
+ if (!isExpectedRevision(v)) throw new AgentError("invalid", "expectedRevision is required (the revision you read)")
191
+ return v
192
+ }
193
+
194
+ function isSpecShape(v: unknown): v is TeamSpec {
195
+ return isObj(v) && typeof v.name === "string" && typeof v.title === "string" && typeof v.entryRole === "string" && Array.isArray(v.roles) && Array.isArray(v.edges) && (v.mode === "open" || v.mode === "restricted")
196
+ }
197
+
198
+ /** A stored team as this build reads it (newer versions are refused, malformed ones reported). */
199
+ export function readTeam(key: string, raw: unknown): Readout {
200
+ if (!isObj(raw)) return { ok: false, why: "malformed" }
201
+ const v = raw.v
202
+ if (isNum(v) && v > CONTRACT_VERSION) return { ok: false, why: "newer", v }
203
+ if (v !== CONTRACT_VERSION) return { ok: false, why: "malformed" }
204
+ const draft = raw.draft
205
+ const valid =
206
+ raw.id === key &&
207
+ isSpecShape(raw.spec) &&
208
+ RECORD_STATES.includes(raw.state as RecordState) &&
209
+ (raw.paused === undefined || raw.paused === true) &&
210
+ Number.isSafeInteger(raw.version) &&
211
+ (raw.version as number) >= 1 &&
212
+ Array.isArray(raw.history) &&
213
+ (draft === undefined || (isObj(draft) && isSpecShape(draft.spec) && typeof draft.digest === "string")) &&
214
+ isNum(raw.createdAt) &&
215
+ isNum(raw.publishedAt) &&
216
+ isNum(raw.updatedAt)
217
+ return valid ? { ok: true, record: raw as unknown as TeamRecord } : { ok: false, why: "malformed" }
218
+ }
219
+
220
+ /** The API view of a record at a StateStore version. */
221
+ export function teamView(record: TeamRecord, revision: number): Team {
222
+ const d = record.draft
223
+ return {
224
+ v: 1,
225
+ id: record.id,
226
+ ...record.spec,
227
+ state: record.state,
228
+ ...(record.paused ? { paused: true as const } : {}),
229
+ version: record.version,
230
+ revision,
231
+ ...(d ? { draft: { baseVersion: d.baseVersion, digest: d.digest, savedAt: d.savedAt } } : {}),
232
+ createdAt: record.createdAt,
233
+ publishedAt: record.publishedAt,
234
+ updatedAt: record.updatedAt,
235
+ }
236
+ }
237
+
238
+ const byCreation = (a: Team, b: Team) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
239
+ const encodeCursor = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url")
240
+ function decodeCursor(cursor: string): unknown {
241
+ try {
242
+ return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown
243
+ } catch {
244
+ throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
245
+ }
246
+ }
247
+ function pageLimit(limit: number | undefined): number {
248
+ const n = limit ?? LIST_LIMIT_DEFAULT
249
+ if (!Number.isSafeInteger(n) || n < 1 || n > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
250
+ return n
251
+ }
252
+
253
+ export class TeamStore {
254
+ readonly state: StateStore
255
+ readonly limits: TeamStoreLimits
256
+ private readonly now: Clock
257
+ private readonly newID: (kind: IdKind) => string
258
+ private readonly pinned?: PinnedVersions
259
+
260
+ constructor(deps: TeamStoreDeps) {
261
+ this.state = deps.state
262
+ this.now = deps.now ?? Date.now
263
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
264
+ this.pinned = deps.pinnedVersions
265
+ this.limits = {
266
+ teamsPerUser: deps.limits?.teamsPerUser ?? TEAM_LIMITS.teamsPerUser,
267
+ versionsRetained: deps.limits?.versionsRetained ?? TEAM_STORE_LIMITS.versionsRetained,
268
+ reservationMs: deps.limits?.reservationMs ?? 60_000,
269
+ }
270
+ }
271
+
272
+ // ---------- create and read ----------
273
+
274
+ /**
275
+ * Creates an active team whose version 1 is published at once (name_taken for a used name, 429 at the cap).
276
+ * With an Idempotency-Key, the same key and body within IDEMPOTENCY_TTL_MS return the original (created false);
277
+ * another body is a conflict.
278
+ */
279
+ async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ team: Team; created: boolean }> {
280
+ const spec = teamSpecOf(input)
281
+ const key = options.idempotencyKey
282
+ if (key === undefined) return { team: await this.insert(userID, this.newID("team"), spec), created: true }
283
+ if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
284
+ const digest = specDigest(spec)
285
+ await this.pruneKeys(userID)
286
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
287
+ const held = await this.state.get<IdempotencyDoc>(userID, TEAM_IDEMPOTENCY, key)
288
+ const live = held && isObj(held.value) && isNum(held.value.at) && this.now() - held.value.at < IDEMPOTENCY_TTL_MS ? held.value : undefined
289
+ if (live) {
290
+ if (live.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for a different team`, { hint: "Use a new key for a different body, or resend the original body" })
291
+ const existing = await this.readSoon(userID, live.teamID)
292
+ if (existing) return { team: existing, created: false }
293
+ if (this.now() - live.at < this.limits.reservationMs)
294
+ throw new AgentError("rate_limited", `The team for Idempotency-Key ${key} is still being created`, { extra: { retryAfter: 1 } })
295
+ }
296
+ const id = this.newID("team")
297
+ const claimed = await this.state.put<IdempotencyDoc>(userID, TEAM_IDEMPOTENCY, key, { v: 1, teamID: id, digest, at: this.now() }, { ifVersion: held?.version ?? 0 })
298
+ if (!claimed.ok) continue
299
+ try {
300
+ return { team: await this.insert(userID, id, spec), created: true }
301
+ } catch (error) {
302
+ await this.state.delete(userID, TEAM_IDEMPOTENCY, key, { ifVersion: claimed.version })
303
+ throw error
304
+ }
305
+ }
306
+ throw new Error(`team idempotency key ${key} kept conflicting`)
307
+ }
308
+
309
+ /** The user's team, or undefined (also another user's); throws for records this build cannot read. */
310
+ async get(userID: string, id: string): Promise<Team | undefined> {
311
+ const stored = await this.loaded(userID, id)
312
+ return stored && teamView(stored.record, stored.revision)
313
+ }
314
+
315
+ async require(userID: string, id: string): Promise<Team> {
316
+ const team = await this.get(userID, id)
317
+ if (!team) throw notFound(id)
318
+ return team
319
+ }
320
+
321
+ /** The user's team by its name (the `#team` address), or undefined. */
322
+ async byName(userID: string, name: string): Promise<Team | undefined> {
323
+ const entry = (await this.index(userID))?.names[name]
324
+ if (!entry) return undefined
325
+ const team = await this.get(userID, entry.id)
326
+ return team?.name === name ? team : undefined
327
+ }
328
+
329
+ /** Teams by creation, filtered by state (default active), tags, project, metadata and pause; paged. */
330
+ async list(userID: string, query: TeamListQuery = {}): Promise<Page<Team>> {
331
+ const limit = pageLimit(query.limit)
332
+ const wanted = query.state ?? "active"
333
+ if (wanted !== "all" && !RECORD_STATES.includes(wanted as RecordState)) throw new AgentError("invalid", "state must be active, archived or all")
334
+ const problem = tagsProblem(query.tags) ?? metadataProblem(query.metadata)
335
+ if (problem) throw new AgentError("invalid", problem)
336
+ if (query.project !== undefined && !isLabel(query.project)) throw new AgentError("invalid", "project must be a project name")
337
+ let after: [number, string] | undefined
338
+ if (query.cursor !== undefined) {
339
+ const parsed = decodeCursor(query.cursor)
340
+ if (!Array.isArray(parsed) || parsed.length !== 2 || !isNum(parsed[0]) || typeof parsed[1] !== "string")
341
+ throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
342
+ after = [parsed[0], parsed[1]]
343
+ }
344
+ const all = (await this.readAll(userID))
345
+ .filter((t) => wanted === "all" || t.state === wanted)
346
+ .filter((t) => !query.tags || query.tags.every((tag) => t.tags?.includes(tag)))
347
+ .filter((t) => query.project === undefined || (t.projects ?? []).includes(query.project))
348
+ .filter((t) => !query.metadata || Object.entries(query.metadata).every(([k, v]) => t.metadata?.[k] === v))
349
+ .filter((t) => query.paused === undefined || (t.paused === true) === query.paused)
350
+ .filter((t) => !after || t.createdAt > after[0] || (t.createdAt === after[0] && t.id > after[1]))
351
+ .sort(byCreation)
352
+ const items = all.slice(0, limit)
353
+ return all.length > limit ? { items, cursor: encodeCursor([items.at(-1)!.createdAt, items.at(-1)!.id]) } : { items }
354
+ }
355
+
356
+ // ---------- drafts, publish, history ----------
357
+
358
+ /** The pending draft, if any. */
359
+ async draft(userID: string, id: string): Promise<TeamDraft | undefined> {
360
+ return (await this.requireLoaded(userID, id)).record.draft
361
+ }
362
+
363
+ /** Saves edits as the pending draft (built on the draft or the published spec); the published spec is unchanged. */
364
+ async saveDraft(userID: string, id: string, patch: unknown, expectedRevision: number): Promise<Team> {
365
+ const expected = checkExpected(expectedRevision)
366
+ return this.mutate(userID, id, expected, (record) => {
367
+ this.active(record)
368
+ const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch)
369
+ return { ...record, draft: { baseVersion: record.version, spec, digest: specDigest(spec), savedAt: this.now() } }
370
+ })
371
+ }
372
+
373
+ async discardDraft(userID: string, id: string, expectedRevision: number): Promise<Team> {
374
+ const expected = checkExpected(expectedRevision)
375
+ return this.mutate(userID, id, expected, (record) => {
376
+ if (!record.draft) throw new AgentError("invalid", `Team ${id} has no draft`)
377
+ const { draft: _d, ...rest } = record
378
+ return rest
379
+ })
380
+ }
381
+
382
+ /**
383
+ * Publishes the draft as the next immutable version. A draft equal to the published spec is dropped without a
384
+ * new version (published false). Refused while sessions are staffed in a role the draft removes.
385
+ */
386
+ async publish(userID: string, id: string, expectedRevision: number): Promise<{ team: Team; published: boolean }> {
387
+ const expected = checkExpected(expectedRevision)
388
+ const { record, revision } = await this.requireLoaded(userID, id)
389
+ if (revision !== expected) throw conflict(`Team ${id}`, revision)
390
+ this.active(record)
391
+ const draft = record.draft
392
+ if (!draft) throw new AgentError("invalid", `Team ${id} has no draft to publish`, { hint: "Save a draft first" })
393
+ if (draft.digest === specDigest(record.spec)) {
394
+ const { draft: _d, ...rest } = record
395
+ return { team: await this.write(userID, id, rest, revision), published: false }
396
+ }
397
+ const roles = new Set(draft.spec.roles.map((r) => r.name))
398
+ const staffed = [...new Set(currentSeats(readSeatsDoc((await this.state.get(userID, TEAM_SEATS, id))?.value, id)).map((s) => s.role))].filter((r) => !roles.has(r)).sort()
399
+ if (staffed.length > 0)
400
+ throw new AgentError("invalid", `Sessions are staffed in roles this draft removes: ${staffed.join(", ")}`, { hint: "Unstaff those roles first", extra: { roles: staffed } })
401
+ await this.ensureSlot(userID, record)
402
+ const at = this.now()
403
+ const number = record.version + 1
404
+ const entry: TeamVersionEntry = { version: number, digest: draft.digest, publishedAt: at }
405
+ const pinned = (await this.pinned?.(userID, id)) ?? new Set<number>()
406
+ const history = [...record.history, entry].sort((a, b) => b.version - a.version)
407
+ const kept = history.filter((h, i) => i < this.limits.versionsRetained || pinned.has(h.version))
408
+ const dropped = history.filter((h) => !kept.includes(h))
409
+ const { draft: _d, ...rest } = record
410
+ const team = await this.write(userID, id, { ...rest, spec: draft.spec, version: number, history: kept.sort((a, b) => a.version - b.version), publishedAt: at }, revision)
411
+ await this.state.put<TeamVersion>(userID, teamVersionCollection(id), String(number), { v: 1, teamID: id, version: number, spec: draft.spec, digest: draft.digest, publishedAt: at }, { ifVersion: 0 })
412
+ for (const victim of dropped) await this.state.delete(userID, teamVersionCollection(id), String(victim.version))
413
+ return { team, published: true }
414
+ }
415
+
416
+ /** A retained published revision (undefined when dropped or never published). */
417
+ async version(userID: string, id: string, number: number): Promise<TeamVersion | undefined> {
418
+ const { record } = await this.requireLoaded(userID, id)
419
+ if (!record.history.some((h) => h.version === number)) return undefined
420
+ const slot = await this.state.get<TeamVersion>(userID, teamVersionCollection(id), String(number))
421
+ if (slot) return slot.value
422
+ if (number !== record.version) return undefined
423
+ return this.ensureSlot(userID, record)
424
+ }
425
+
426
+ /** Retained versions, newest first, paged. */
427
+ async history(userID: string, id: string, options: { readonly limit?: number; readonly cursor?: string } = {}): Promise<Page<TeamVersionEntry>> {
428
+ const limit = pageLimit(options.limit)
429
+ let before = Number.POSITIVE_INFINITY
430
+ if (options.cursor !== undefined) {
431
+ const parsed = decodeCursor(options.cursor)
432
+ if (!Number.isSafeInteger(parsed)) throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
433
+ before = parsed as number
434
+ }
435
+ const { record } = await this.requireLoaded(userID, id)
436
+ const all = [...record.history].sort((a, b) => b.version - a.version).filter((h) => h.version < before)
437
+ const items = all.slice(0, limit)
438
+ return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!.version) } : { items }
439
+ }
440
+
441
+ /** What changed between two retained versions, or from a version to the pending draft. */
442
+ async diff(userID: string, id: string, from: number, to: number | "draft"): Promise<TeamDiff> {
443
+ const a = await this.version(userID, id, from)
444
+ if (!a) throw new AgentError("not_found", `Team ${id} has no retained version ${from}`)
445
+ if (to === "draft") {
446
+ const draft = await this.draft(userID, id)
447
+ if (!draft) throw new AgentError("not_found", `Team ${id} has no draft`)
448
+ return diffTeamSpecs(a.spec, draft.spec)
449
+ }
450
+ const b = await this.version(userID, id, to)
451
+ if (!b) throw new AgentError("not_found", `Team ${id} has no retained version ${to}`)
452
+ return diffTeamSpecs(a.spec, b.spec)
453
+ }
454
+
455
+ // ---------- status ----------
456
+
457
+ /** Pauses an active team (intake and new staffing stop; HWA-35/36). Pausing a paused team is a no-op. */
458
+ async pause(userID: string, id: string, expectedRevision: number): Promise<Team> {
459
+ const expected = checkExpected(expectedRevision)
460
+ return this.mutate(userID, id, expected, (record) => {
461
+ this.active(record)
462
+ return record.paused ? undefined : { ...record, paused: true }
463
+ })
464
+ }
465
+
466
+ async resume(userID: string, id: string, expectedRevision: number): Promise<Team> {
467
+ const expected = checkExpected(expectedRevision)
468
+ return this.mutate(userID, id, expected, (record) => {
469
+ this.active(record)
470
+ if (!record.paused) return undefined
471
+ const { paused: _p, ...rest } = record
472
+ return rest
473
+ })
474
+ }
475
+
476
+ /** Archives the team (reversible; its name stays reserved). Archiving an archived team is a no-op. */
477
+ async archive(userID: string, id: string, expectedRevision: number): Promise<Team> {
478
+ const expected = checkExpected(expectedRevision)
479
+ const team = await this.mutate(userID, id, expected, (record) => (record.state === "archived" ? undefined : { ...record, state: "archived" }))
480
+ await this.markArchived(userID, team.name, id, true)
481
+ return team
482
+ }
483
+
484
+ /** Makes an archived team active again (429 at the cap). Restoring an active team is a no-op. */
485
+ async restore(userID: string, id: string, expectedRevision: number): Promise<Team> {
486
+ const expected = checkExpected(expectedRevision)
487
+ const { record, revision } = await this.requireLoaded(userID, id)
488
+ if (revision !== expected) throw conflict(`Team ${id}`, revision)
489
+ if (record.state === "active") return teamView(record, revision)
490
+ await this.markArchived(userID, record.spec.name, id, false)
491
+ try {
492
+ return await this.mutate(userID, id, expected, (r) => (r.state === "active" ? undefined : { ...r, state: "active" }))
493
+ } catch (error) {
494
+ await this.markArchived(userID, record.spec.name, id, true)
495
+ throw error
496
+ }
497
+ }
498
+
499
+ // ---------- owner settings ----------
500
+
501
+ async settings(userID: string): Promise<TeamSettings> {
502
+ const stored = await this.state.get<SettingsDoc>(userID, TEAM_SETTINGS, OWNER)
503
+ if (!stored) return { teamIsolation: false, revision: 0 }
504
+ return this.settingsOf(stored.value, stored.version)
505
+ }
506
+
507
+ /** Changes the owner's team settings under CAS (expectedRevision 0 when never set). */
508
+ async setSettings(userID: string, input: unknown, expectedRevision: number): Promise<TeamSettings> {
509
+ const expected = checkExpected(expectedRevision)
510
+ if (!isObj(input) || Object.keys(input).some((k) => k !== "teamIsolation") || typeof input.teamIsolation !== "boolean")
511
+ throw new AgentError("invalid", "Send {teamIsolation: true|false}")
512
+ const current = await this.state.get<SettingsDoc>(userID, TEAM_SETTINGS, OWNER)
513
+ if (current) this.settingsOf(current.value, current.version)
514
+ const doc: SettingsDoc = { v: 1, teamIsolation: input.teamIsolation, updatedAt: this.now() }
515
+ const result = await this.state.put(userID, TEAM_SETTINGS, OWNER, doc, { ifVersion: expected })
516
+ if (!result.ok) throw conflict("Team settings", result.version)
517
+ return { teamIsolation: doc.teamIsolation, revision: result.version, updatedAt: doc.updatedAt }
518
+ }
519
+
520
+ /** Makes buffered writes durable (standalone debounce); a no-op for write-through stores. */
521
+ async flush(): Promise<void> {
522
+ await this.state.flush?.()
523
+ }
524
+
525
+ // ---------- internals ----------
526
+
527
+ private settingsOf(raw: unknown, version: number): TeamSettings {
528
+ if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION) throw new AgentError("unsupported", "Team settings were written by a newer hub", { extra: { feature: "team-settings" } })
529
+ if (!isObj(raw) || typeof raw.teamIsolation !== "boolean") throw new AgentError("internal", "Stored team settings are malformed")
530
+ return { teamIsolation: raw.teamIsolation, revision: version, ...(isNum(raw.updatedAt) ? { updatedAt: raw.updatedAt } : {}) }
531
+ }
532
+
533
+ private active(record: TeamRecord): void {
534
+ if (record.state === "archived") throw new AgentError("invalid", `Team ${record.id} is archived`, { hint: "Restore it first" })
535
+ }
536
+
537
+ private async loaded(userID: string, id: string): Promise<{ record: TeamRecord; revision: number } | undefined> {
538
+ const stored = await this.state.get(userID, TEAMS, id)
539
+ if (!stored) return undefined
540
+ const read = readTeam(id, stored.value)
541
+ if (read.ok) return { record: read.record, revision: stored.version }
542
+ if (read.why === "newer") throw new AgentError("unsupported", `Team ${id} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "teams", recordVersion: read.v } })
543
+ throw new AgentError("internal", `Stored team ${id} is malformed`)
544
+ }
545
+
546
+ private async requireLoaded(userID: string, id: string): Promise<{ record: TeamRecord; revision: number }> {
547
+ const stored = await this.loaded(userID, id)
548
+ if (!stored) throw notFound(id)
549
+ return stored
550
+ }
551
+
552
+ private async readAll(userID: string): Promise<Team[]> {
553
+ const out: Team[] = []
554
+ for (const entry of await this.state.list(userID, TEAMS)) {
555
+ const read = readTeam(entry.key, entry.value)
556
+ if (read.ok) out.push(teamView(read.record, entry.version))
557
+ }
558
+ return out
559
+ }
560
+
561
+ private async readSoon(userID: string, id: string): Promise<Team | undefined> {
562
+ for (let i = 0; i < READS; i++) {
563
+ const team = await this.get(userID, id)
564
+ if (team) return team
565
+ }
566
+ return undefined
567
+ }
568
+
569
+ /** Version-checked change of a record; `change` returning undefined leaves it alone. */
570
+ private async mutate(userID: string, id: string, expected: number, change: (record: TeamRecord) => TeamRecord | undefined): Promise<Team> {
571
+ const { record, revision } = await this.requireLoaded(userID, id)
572
+ if (revision !== expected) throw conflict(`Team ${id}`, revision)
573
+ const next = change(record)
574
+ if (!next) return teamView(record, revision)
575
+ return this.write(userID, id, next, revision)
576
+ }
577
+
578
+ private async write(userID: string, id: string, next: TeamRecord, revision: number): Promise<Team> {
579
+ const written: TeamRecord = { ...next, v: 1, id, updatedAt: this.now() }
580
+ const result = await this.state.put(userID, TEAMS, id, written, { ifVersion: revision })
581
+ if (!result.ok) throw conflict(`Team ${id}`, result.version)
582
+ return teamView(written, result.version)
583
+ }
584
+
585
+ private async insert(userID: string, id: string, spec: TeamSpec): Promise<Team> {
586
+ const at = this.now()
587
+ await this.reserveName(userID, spec.name, id, at)
588
+ try {
589
+ const digest = specDigest(spec)
590
+ const record: TeamRecord = { v: 1, id, spec, state: "active", version: 1, history: [{ version: 1, digest, publishedAt: at }], createdAt: at, publishedAt: at, updatedAt: at }
591
+ const written = await this.state.put(userID, TEAMS, id, record, { ifVersion: 0 })
592
+ if (!written.ok) throw new Error(`team id ${id} already exists`)
593
+ await this.state.put<TeamVersion>(userID, teamVersionCollection(id), "1", { v: 1, teamID: id, version: 1, spec, digest, publishedAt: at }, { ifVersion: 0 })
594
+ return teamView(record, written.version)
595
+ } catch (error) {
596
+ await this.releaseName(userID, spec.name, id).catch(() => {})
597
+ throw error
598
+ }
599
+ }
600
+
601
+ /** Writes the slot of the record's current version when it is missing (crash between record and slot). */
602
+ private async ensureSlot(userID: string, record: TeamRecord): Promise<TeamVersion> {
603
+ const key = String(record.version)
604
+ const slot = await this.state.get<TeamVersion>(userID, teamVersionCollection(record.id), key)
605
+ if (slot) return slot.value
606
+ const entry = record.history.find((h) => h.version === record.version)
607
+ const version: TeamVersion = { v: 1, teamID: record.id, version: record.version, spec: record.spec, digest: entry?.digest ?? specDigest(record.spec), publishedAt: record.publishedAt }
608
+ await this.state.put(userID, teamVersionCollection(record.id), key, version, { ifVersion: 0 })
609
+ return version
610
+ }
611
+
612
+ private async index(userID: string): Promise<NameIndex | undefined> {
613
+ const raw = (await this.state.get<NameIndex>(userID, TEAM_INDEX, NAMES))?.value
614
+ return isObj(raw) && isObj(raw.names) ? raw : undefined
615
+ }
616
+
617
+ /** Reserves the name for `id` (name_taken; 429 when the user has teamsPerUser active teams). */
618
+ private async reserveName(userID: string, name: string, id: string, now: number): Promise<void> {
619
+ const ids = new Set((await this.state.list(userID, TEAMS)).map((e) => e.key))
620
+ const dead = (e: NameEntry) => !ids.has(e.id) && now - e.at > this.limits.reservationMs
621
+ await updateVersioned<NameIndex>(this.state, userID, TEAM_INDEX, NAMES, (current) => {
622
+ const names: Record<string, NameEntry> = {}
623
+ for (const [n, e] of Object.entries(current?.names ?? {})) if (!dead(e)) names[n] = e
624
+ if (names[name]) throw new AgentError("name_taken", `You already have a team named ${name}`, { hint: "Pick another name, or restore the archived team" })
625
+ const active = Object.values(names).filter((e) => !e.archived).length
626
+ if (active >= this.limits.teamsPerUser)
627
+ throw new AgentError("rate_limited", `A user has at most ${this.limits.teamsPerUser} active teams`, { hint: "Archive a team first", extra: { limit: this.limits.teamsPerUser } })
628
+ names[name] = { id, at: now }
629
+ return { v: 1, names }
630
+ })
631
+ }
632
+
633
+ private async releaseName(userID: string, name: string, id: string): Promise<void> {
634
+ await updateVersioned<NameIndex>(this.state, userID, TEAM_INDEX, NAMES, (current) => {
635
+ if (current?.names[name]?.id !== id) return undefined
636
+ const names = { ...current.names }
637
+ delete names[name]
638
+ return { v: 1, names }
639
+ })
640
+ }
641
+
642
+ /** Flags the name entry archived (frees a cap slot) or active again (429 at the cap). */
643
+ private async markArchived(userID: string, name: string, id: string, archived: boolean): Promise<void> {
644
+ await updateVersioned<NameIndex>(this.state, userID, TEAM_INDEX, NAMES, (current) => {
645
+ const names = { ...current?.names }
646
+ const entry = names[name]
647
+ if (entry && entry.id !== id) return undefined
648
+ if (entry && (entry.archived === true) === archived) return undefined
649
+ if (archived) names[name] = { id, at: entry?.at ?? this.now(), archived: true }
650
+ else {
651
+ const active = Object.values(names).filter((e) => !e.archived).length
652
+ if (active >= this.limits.teamsPerUser)
653
+ throw new AgentError("rate_limited", `A user has at most ${this.limits.teamsPerUser} active teams`, { hint: "Archive a team first", extra: { limit: this.limits.teamsPerUser } })
654
+ names[name] = { id, at: entry?.at ?? this.now() }
655
+ }
656
+ return { v: 1, names }
657
+ })
658
+ }
659
+
660
+ private async pruneKeys(userID: string): Promise<void> {
661
+ const now = this.now()
662
+ for (const entry of await this.state.list<IdempotencyDoc>(userID, TEAM_IDEMPOTENCY)) {
663
+ const at = isObj(entry.value) && isNum(entry.value.at) ? entry.value.at : 0
664
+ if (now - at >= IDEMPOTENCY_TTL_MS) await this.state.delete(userID, TEAM_IDEMPOTENCY, entry.key, { ifVersion: entry.version })
665
+ }
666
+ }
667
+ }