@huynle/hub 0.7.4 → 0.8.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.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,632 @@
1
+ // Workspace membership and versioned assignments (HWA-04; contract docs/plans/2026-10-30-hub-workspaces-artifacts-
2
+ // contracts.md, packages/protocol/src/hub-workspace.ts WorkspaceMember / WorkspaceAssignment).
3
+ //
4
+ // Storage: StateStore collections under the hub user (the tenant key), like the workspace store (store.ts):
5
+ // - `workspace-members`: one aggregate per workspace (key: workspace ID) holding its WorkspaceMembers, current and
6
+ // departed. One aggregate makes the per-workspace cap, "one current membership per session" and an internal
7
+ // master replacement (old member `replaced`, new one generation + 1) a single version-checked write. Each member
8
+ // carries its own `revision` (1 on join, +1 per change); a stale `expectedRevision` is a 409 conflict with
9
+ // `currentRevision`.
10
+ // - `workspace-assignments`: one aggregate per workspace holding its assignments and the assignment
11
+ // Idempotency-Keys (so a key's claim and its assignment are written together: no crash window). Per-assignment
12
+ // `revision` as for members, so two reassignments under one revision: exactly one wins.
13
+ //
14
+ // Session references: members name sessions by ID and never own them. Joining needs a session the user's
15
+ // directory knows (unknown or another user's: not_found; deleted: invalid). Offline and archived sessions may join.
16
+ // Reads report every reference truthfully through the presence lookup (online / offline / archived / deleted /
17
+ // unknown) and an assignment's member as current / left / missing; nothing is hidden or silently repaired.
18
+ //
19
+ // Lifecycle and retention:
20
+ // - Departure is explicit: leave (`removed`), sessionEnded (`ended`, for the hub's session-deleted path), an internal
21
+ // replacement (`replaced`) or archiving the workspace (`archived`). Departed records are kept (they count toward
22
+ // membersPerWorkspace, as the contract says); a join at the cap prunes the oldest departed member that no open
23
+ // assignment references, and answers 429 rate_limited when there is none.
24
+ // - Archiving the workspace departs every current member (`archived`); restore re-joins nobody (a re-join is a new
25
+ // generation). Assignments keep their status. Mutations of an archived workspace's members/assignments are invalid.
26
+ // - Assignment completion is explicit: only setStatus moves an assignment to `done` or `cancelled`; nothing infers it
27
+ // from idleness, presence or departure. Closed assignments are final and retained up to closedAssignmentsRetained
28
+ // per workspace (oldest closed dropped); open ones are capped at openAssignmentsPerWorkspace (429).
29
+ //
30
+ // Authority: membership, roles and assignments grant nothing. This store holds records only; grants, native
31
+ // parentID/rootID, the directory and UserRecord.agent are never touched. The master role is not given by joining:
32
+ // HWA-12's explicit replacement passes `allowMaster` and maintains HubWorkspace.masterMemberID.
33
+ // Assignment text (title, criteria) never belongs in events; events (HWA-06) carry IDs, revisions and counts only.
34
+ //
35
+ // Other builds: an aggregate with a newer `v` answers 422 unsupported and is left untouched; a malformed one is a
36
+ // 500. Rollback: older hubs never read these collections, so they stay dormant.
37
+ import { randomBytes } from "node:crypto"
38
+ import {
39
+ CONTRACT_VERSION,
40
+ ID_PREFIXES,
41
+ IDEMPOTENCY_KEY,
42
+ IDEMPOTENCY_TTL_MS,
43
+ type IdKind,
44
+ isContractID,
45
+ isExpectedRevision,
46
+ isLabel,
47
+ LIST_LIMIT_DEFAULT,
48
+ LIST_LIMIT_MAX,
49
+ type Page,
50
+ type WorkspaceAssignment,
51
+ type WorkspaceMember,
52
+ WORKSPACE_LIMITS,
53
+ WORKSPACE_ROLES,
54
+ type WorkspaceRole,
55
+ type HubWorkspace,
56
+ } from "../../protocol"
57
+ import type { AgentSession } from "../agent-data"
58
+ import type { Directory } from "../agent/directory"
59
+ import { AgentError } from "../agent/errors"
60
+ import { updateVersioned } from "../agent/versioned"
61
+ import type { Clock, StateStore } from "../backend/types"
62
+ import { digestOf, type WorkspaceStore } from "./store"
63
+
64
+ export const WORKSPACE_MEMBERS = "workspace-members"
65
+ export const WORKSPACE_ASSIGNMENTS = "workspace-assignments"
66
+
67
+ export type AssignmentStatus = WorkspaceAssignment["status"]
68
+ export const ASSIGNMENT_STATUSES: readonly AssignmentStatus[] = ["open", "in_progress", "blocked", "done", "cancelled"]
69
+ /** Statuses an assignment may be created with (closing is an explicit later step). */
70
+ const OPENING: readonly AssignmentStatus[] = ["open", "in_progress", "blocked"]
71
+ const CLOSED: ReadonlySet<AssignmentStatus> = new Set(["done", "cancelled"])
72
+ /** Session IDs as the directory resolves them (`ses_…`), bounded. */
73
+ export const SESSION_ID = /^ses_[A-Za-z0-9]{1,124}$/
74
+
75
+ export interface MembershipLimits {
76
+ /** Members per workspace, current and departed together (the contract's membersPerWorkspace). */
77
+ readonly membersPerWorkspace: number
78
+ /** Assignments not yet done or cancelled, per workspace. */
79
+ readonly openAssignmentsPerWorkspace: number
80
+ /** Closed assignments kept per workspace (oldest closed dropped beyond it). */
81
+ readonly closedAssignmentsRetained: number
82
+ /** Outcome criteria per assignment, and characters per criterion. */
83
+ readonly criteriaPerAssignment: number
84
+ readonly criterionMax: number
85
+ /** Characters of an assignment's external reference. */
86
+ readonly refMax: number
87
+ }
88
+
89
+ export const MEMBERSHIP_LIMITS: MembershipLimits = {
90
+ membersPerWorkspace: WORKSPACE_LIMITS.membersPerWorkspace,
91
+ openAssignmentsPerWorkspace: 200,
92
+ closedAssignmentsRetained: 100,
93
+ criteriaPerAssignment: 8,
94
+ criterionMax: 200,
95
+ refMax: 200,
96
+ }
97
+
98
+ /** Idempotency-Keys kept per workspace (beyond the TTL bound, oldest dropped). */
99
+ const KEYS_MAX = 500
100
+
101
+ /** What the hub knows about a referenced session now. `unknown`: not (or no longer) in the user's directory. */
102
+ export type SessionPresence = "online" | "offline" | "archived" | "deleted" | "unknown"
103
+ export type SessionPresenceLookup = (userID: string, sessionID: string) => SessionPresence | Promise<SessionPresence>
104
+
105
+ /**
106
+ * A WorkspaceAssignment as stored: the contract fields plus outcome criteria (what "done" means, bounded text;
107
+ * never in events), creation time and when it was closed.
108
+ */
109
+ export interface StoredAssignment extends WorkspaceAssignment {
110
+ readonly criteria?: readonly string[]
111
+ readonly createdAt: number
112
+ readonly closedAt?: number
113
+ }
114
+
115
+ export interface MemberView {
116
+ readonly member: WorkspaceMember
117
+ /** Not departed. */
118
+ readonly current: boolean
119
+ readonly presence: SessionPresence
120
+ }
121
+
122
+ export interface AssignmentView {
123
+ readonly assignment: StoredAssignment
124
+ /** The assignee's membership: current, departed (left) or no longer retained (missing). */
125
+ readonly member: "current" | "left" | "missing"
126
+ /** The assignee session's presence (unknown when the member is missing). */
127
+ readonly presence: SessionPresence
128
+ }
129
+
130
+ export interface AssignmentListQuery {
131
+ readonly memberID?: string
132
+ readonly status?: string
133
+ readonly limit?: number
134
+ readonly cursor?: string
135
+ }
136
+
137
+ export interface JoinOptions {
138
+ /** Internal (HWA-12): allows role master; routes for members never pass it. */
139
+ readonly allowMaster?: boolean
140
+ /** Internal (HWA-12): departs this current member as `replaced`; the new member gets its generation + 1. */
141
+ readonly replaces?: string
142
+ /** Internal (HWA-12): the new member's generation is at least this (a master slot re-staffed after its master left). */
143
+ readonly minGeneration?: number
144
+ }
145
+
146
+ export interface MembershipStoreDeps {
147
+ readonly state: StateStore
148
+ readonly workspaces: WorkspaceStore
149
+ readonly presence: SessionPresenceLookup
150
+ readonly now?: Clock
151
+ readonly newID?: (kind: IdKind) => string
152
+ readonly limits?: Partial<MembershipLimits>
153
+ }
154
+
155
+ interface MembersDoc {
156
+ readonly v: 1
157
+ readonly workspaceID: string
158
+ readonly members: readonly WorkspaceMember[]
159
+ }
160
+ interface KeyEntry {
161
+ readonly key: string
162
+ readonly assignmentID: string
163
+ readonly digest: string
164
+ readonly at: number
165
+ }
166
+ interface AssignmentsDoc {
167
+ readonly v: 1
168
+ readonly workspaceID: string
169
+ readonly assignments: readonly StoredAssignment[]
170
+ readonly keys: readonly KeyEntry[]
171
+ }
172
+
173
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
174
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
175
+
176
+ const conflict = (what: string, currentRevision: number) =>
177
+ new AgentError("conflict", `${what} changed since you read it (revision ${currentRevision} now)`, {
178
+ hint: "Read it again and retry with its current revision",
179
+ extra: { currentRevision },
180
+ })
181
+
182
+ const archivedError = (workspaceID: string) => new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
183
+
184
+ function checkExpected(expectedRevision: number | undefined): void {
185
+ if (expectedRevision !== undefined && !isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
186
+ }
187
+
188
+ /** A stored per-workspace aggregate, or undefined when missing; newer versions are unsupported, bad ones internal. */
189
+ function aggregate<T>(raw: unknown, workspaceID: string, field: string, feature: string): T | undefined {
190
+ if (raw === undefined) return undefined
191
+ if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION)
192
+ throw new AgentError("unsupported", `Workspace ${workspaceID} ${feature} were written by a newer hub`, { hint: "Upgrade this hub", extra: { feature } })
193
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || !Array.isArray(raw[field]))
194
+ throw new AgentError("internal", `Stored workspace ${feature} are malformed`)
195
+ return raw as T
196
+ }
197
+
198
+ const membersDoc = (raw: unknown, workspaceID: string) => aggregate<MembersDoc>(raw, workspaceID, "members", "workspace-members")
199
+ function assignmentsDoc(raw: unknown, workspaceID: string): AssignmentsDoc | undefined {
200
+ const doc = aggregate<AssignmentsDoc>(raw, workspaceID, "assignments", "workspace-assignments")
201
+ return doc && { ...doc, keys: Array.isArray(doc.keys) ? doc.keys : [] }
202
+ }
203
+
204
+ function encodeCursor(a: StoredAssignment): string {
205
+ return Buffer.from(JSON.stringify([a.createdAt, a.id])).toString("base64url")
206
+ }
207
+
208
+ function decodeCursor(cursor: string): [number, string] {
209
+ try {
210
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown
211
+ if (Array.isArray(parsed) && parsed.length === 2 && isNum(parsed[0]) && typeof parsed[1] === "string") return [parsed[0], parsed[1]]
212
+ } catch {
213
+ // answered below
214
+ }
215
+ throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
216
+ }
217
+
218
+ const byCreation = (a: StoredAssignment, b: StoredAssignment) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
219
+
220
+ /**
221
+ * Presence from the agent directory: exact session IDs of this user only (a prefix or another user's session is
222
+ * unknown). `sessions` gives the user's stored session records (for deletedAt, which directory entries omit).
223
+ */
224
+ export function directoryPresence(directory: Pick<Directory, "resolve">, sessions: (userID: string) => readonly AgentSession[]): SessionPresenceLookup {
225
+ return (userID, sessionID) => {
226
+ if (!SESSION_ID.test(sessionID)) return "unknown"
227
+ if (sessions(userID).some((s) => s.sessionID === sessionID && s.deletedAt !== undefined)) return "deleted"
228
+ let found
229
+ try {
230
+ found = directory.resolve(userID, sessionID).sessions.find((s) => s.sessionID === sessionID)
231
+ } catch (error) {
232
+ if (error instanceof AgentError) return "unknown"
233
+ throw error
234
+ }
235
+ if (!found) return "unknown"
236
+ if (found.archived) return "archived"
237
+ return found.online ? "online" : "offline"
238
+ }
239
+ }
240
+
241
+ export class MembershipStore {
242
+ readonly limits: MembershipLimits
243
+ private readonly state: StateStore
244
+ private readonly workspaces: WorkspaceStore
245
+ private readonly now: Clock
246
+ private readonly newID: (kind: IdKind) => string
247
+
248
+ constructor(private readonly deps: MembershipStoreDeps) {
249
+ this.state = deps.state
250
+ this.workspaces = deps.workspaces
251
+ this.now = deps.now ?? Date.now
252
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
253
+ this.limits = { ...MEMBERSHIP_LIMITS, ...deps.limits }
254
+ }
255
+
256
+ // ---------- membership ----------
257
+
258
+ /**
259
+ * Adds the session as a member. The same session already a current member with the same role returns that
260
+ * membership (created false); with another role it is a conflict. Not the master role (see JoinOptions).
261
+ */
262
+ async join(userID: string, workspaceID: string, input: unknown, options: JoinOptions = {}): Promise<{ member: WorkspaceMember; created: boolean }> {
263
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
264
+ const { sessionID, role } = input
265
+ if (typeof sessionID !== "string" || !SESSION_ID.test(sessionID)) throw new AgentError("invalid", "sessionID must be a session ID (ses_…)")
266
+ if (!WORKSPACE_ROLES.includes(role as WorkspaceRole)) throw new AgentError("invalid", `role must be one of ${WORKSPACE_ROLES.join(", ")}`)
267
+ if (role === "master" && !options.allowMaster)
268
+ throw new AgentError("invalid", "Joining does not make a master", { hint: "The master is set by explicit replacement (POST /workspaces/:id/master)" })
269
+ if (options.replaces !== undefined && !isContractID("member", options.replaces)) throw new AgentError("invalid", "replaces must be a member ID")
270
+ await this.active(userID, workspaceID)
271
+ const presence = await this.deps.presence(userID, sessionID)
272
+ if (presence === "unknown") throw new AgentError("not_found", `No session ${sessionID}`)
273
+ if (presence === "deleted") throw new AgentError("invalid", `Session ${sessionID} was deleted`)
274
+ const busy = await this.openAssignees(userID, workspaceID)
275
+ let result: { member: WorkspaceMember; created: boolean } | undefined
276
+ await updateVersioned<MembersDoc>(this.state, userID, WORKSPACE_MEMBERS, workspaceID, (raw) => {
277
+ let list = [...(membersDoc(raw, workspaceID)?.members ?? [])]
278
+ const same = list.find((m) => m.sessionID === sessionID && m.leftAt === undefined)
279
+ if (same) {
280
+ if (same.role !== role)
281
+ throw new AgentError("conflict", `Session ${sessionID} is already a ${same.role} here`, { hint: "Change its role instead", extra: { currentRevision: same.revision } })
282
+ result = { member: same, created: false }
283
+ return undefined
284
+ }
285
+ const at = this.now()
286
+ let generation = 1 + Math.max(0, ...list.filter((m) => m.sessionID === sessionID).map((m) => m.generation))
287
+ if (options.replaces !== undefined) {
288
+ const old = list.find((m) => m.id === options.replaces)
289
+ if (!old) throw new AgentError("not_found", `No member ${options.replaces} in workspace ${workspaceID}`)
290
+ if (old.leftAt !== undefined) throw new AgentError("invalid", `Member ${old.id} already left`)
291
+ generation = old.generation + 1
292
+ list = list.map((m) => (m.id === old.id ? { ...m, leftAt: at, leftReason: "replaced", revision: m.revision + 1 } : m))
293
+ }
294
+ if (options.minGeneration !== undefined) generation = Math.max(generation, options.minGeneration)
295
+ if (list.length >= this.limits.membersPerWorkspace) {
296
+ const prunable = list.filter((m) => m.leftAt !== undefined && !busy.has(m.id)).sort((a, b) => a.leftAt! - b.leftAt!)[0]
297
+ if (!prunable)
298
+ throw new AgentError("rate_limited", `A workspace has at most ${this.limits.membersPerWorkspace} members, current and departed`, {
299
+ hint: "Remove members, or close the open assignments of departed ones",
300
+ })
301
+ list = list.filter((m) => m.id !== prunable.id)
302
+ }
303
+ const member: WorkspaceMember = { v: 1, id: this.newID("member"), workspaceID, sessionID, role: role as WorkspaceRole, generation, joinedAt: at, revision: 1 }
304
+ result = { member, created: true }
305
+ return { v: 1, workspaceID, members: [...list, member] }
306
+ })
307
+ return result!
308
+ }
309
+
310
+ /** One member (current or departed); not_found otherwise. */
311
+ async member(userID: string, workspaceID: string, memberID: string): Promise<WorkspaceMember> {
312
+ const member = (await this.readMembers(userID, workspaceID)).find((m) => m.id === memberID)
313
+ if (!member) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
314
+ return member
315
+ }
316
+
317
+ /** The workspace's members by join order (current only unless includeLeft), with their sessions' presence. */
318
+ async members(userID: string, workspaceID: string, query: { readonly includeLeft?: boolean } = {}): Promise<MemberView[]> {
319
+ const members = (await this.readMembers(userID, workspaceID)).filter((m) => query.includeLeft || m.leftAt === undefined)
320
+ const presence = await this.presenceOf(userID, members.map((m) => m.sessionID))
321
+ return members.map((member) => ({ member, current: member.leftAt === undefined, presence: presence.get(member.sessionID)! }))
322
+ }
323
+
324
+ /** Changes a current member's role between worker and observer (master: HWA-12's replacement). */
325
+ async setRole(userID: string, workspaceID: string, memberID: string, role: unknown, expectedRevision?: number): Promise<WorkspaceMember> {
326
+ checkExpected(expectedRevision)
327
+ if (role !== "worker" && role !== "observer") throw new AgentError("invalid", "role must be worker or observer", { hint: "The master is set by explicit replacement" })
328
+ await this.active(userID, workspaceID)
329
+ return this.mutateMember(userID, workspaceID, memberID, expectedRevision, (m) => {
330
+ if (m.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`, { hint: "Join the session again" })
331
+ if (m.role === "master") throw new AgentError("invalid", `Member ${memberID} is the master`, { hint: "Replace the master instead" })
332
+ return m.role === role ? undefined : { ...m, role }
333
+ })
334
+ }
335
+
336
+ /** Departs a member (record kept). Leaving again is a no-op. Allowed on an archived workspace. */
337
+ async leave(
338
+ userID: string,
339
+ workspaceID: string,
340
+ memberID: string,
341
+ options: { readonly expectedRevision?: number; readonly reason?: "removed" | "ended" } = {},
342
+ ): Promise<WorkspaceMember> {
343
+ checkExpected(options.expectedRevision)
344
+ const reason = options.reason ?? "removed"
345
+ const workspace = await this.workspaces.require(userID, workspaceID)
346
+ const left = await this.mutateMember(userID, workspaceID, memberID, options.expectedRevision, (m) =>
347
+ m.leftAt !== undefined ? undefined : { ...m, leftAt: this.now(), leftReason: reason },
348
+ )
349
+ if (workspace.masterMemberID === memberID) await this.workspaces.setMaster(userID, workspaceID, undefined)
350
+ return left
351
+ }
352
+
353
+ /** The session's current memberships across the user's workspaces. */
354
+ async membershipsOf(userID: string, sessionID: string): Promise<{ workspaceID: string; member: WorkspaceMember }[]> {
355
+ const out: { workspaceID: string; member: WorkspaceMember }[] = []
356
+ for (const entry of await this.state.list(userID, WORKSPACE_MEMBERS)) {
357
+ let doc: MembersDoc | undefined
358
+ try {
359
+ doc = membersDoc(entry.value, entry.key)
360
+ } catch {
361
+ continue
362
+ }
363
+ for (const member of doc?.members ?? []) if (member.sessionID === sessionID && member.leftAt === undefined) out.push({ workspaceID: entry.key, member })
364
+ }
365
+ return out
366
+ }
367
+
368
+ /** (RP-08) Current memberships of several sessions, in one scan of the user's workspaces. */
369
+ async membershipsOfMany(userID: string, sessionIDs: readonly string[]): Promise<Map<string, string[]>> {
370
+ const wanted = new Set(sessionIDs)
371
+ const out = new Map<string, string[]>()
372
+ for (const entry of await this.state.list(userID, WORKSPACE_MEMBERS)) {
373
+ let doc: MembersDoc | undefined
374
+ try {
375
+ doc = membersDoc(entry.value, entry.key)
376
+ } catch {
377
+ continue
378
+ }
379
+ for (const member of doc?.members ?? []) {
380
+ if (!wanted.has(member.sessionID) || member.leftAt !== undefined) continue
381
+ const list = out.get(member.sessionID) ?? []
382
+ if (!list.includes(entry.key)) list.push(entry.key)
383
+ out.set(member.sessionID, list)
384
+ }
385
+ }
386
+ return out
387
+ }
388
+
389
+ /** The session ended (deleted): departs its current memberships as `ended`; returns those it departed. */
390
+ async sessionEnded(userID: string, sessionID: string): Promise<WorkspaceMember[]> {
391
+ const ended: WorkspaceMember[] = []
392
+ for (const { workspaceID, member } of await this.membershipsOf(userID, sessionID)) {
393
+ const left = await this.leave(userID, workspaceID, member.id, { reason: "ended" })
394
+ if (left.leftReason === "ended" && left.revision !== member.revision) ended.push(left)
395
+ }
396
+ return ended
397
+ }
398
+
399
+ /**
400
+ * Archives the workspace (WorkspaceStore.archive) and departs its current members as `archived`. Re-running it
401
+ * finishes a sweep a crash interrupted. Assignments keep their status.
402
+ */
403
+ async archiveWorkspace(userID: string, workspaceID: string, expectedRevision?: number): Promise<{ workspace: HubWorkspace; departed: number }> {
404
+ const workspace = await this.workspaces.archive(userID, workspaceID, expectedRevision)
405
+ let departed = 0
406
+ await updateVersioned<MembersDoc>(this.state, userID, WORKSPACE_MEMBERS, workspaceID, (raw) => {
407
+ const doc = membersDoc(raw, workspaceID)
408
+ departed = doc?.members.filter((m) => m.leftAt === undefined).length ?? 0
409
+ if (!doc || departed === 0) return undefined
410
+ const at = this.now()
411
+ return { ...doc, members: doc.members.map((m) => (m.leftAt === undefined ? { ...m, leftAt: at, leftReason: "archived" as const, revision: m.revision + 1 } : m)) }
412
+ })
413
+ return { workspace, departed }
414
+ }
415
+
416
+ /** Makes buffered writes durable (standalone debounce). */
417
+ async flush(): Promise<void> {
418
+ await this.state.flush?.()
419
+ }
420
+
421
+ // ---------- assignments ----------
422
+
423
+ /**
424
+ * Gives a current member an assignment (status open unless given in_progress/blocked). With an idempotency key,
425
+ * the same key and body within IDEMPOTENCY_TTL_MS return the original (created false); another body is a conflict.
426
+ */
427
+ async assign(userID: string, workspaceID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ assignment: StoredAssignment; created: boolean }> {
428
+ const body = this.assignmentBody(input)
429
+ const key = options.idempotencyKey
430
+ if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
431
+ await this.active(userID, workspaceID)
432
+ await this.currentMember(userID, workspaceID, body.memberID)
433
+ const digest = digestOf(body)
434
+ let result: { assignment: StoredAssignment; created: boolean } | undefined
435
+ await updateVersioned<AssignmentsDoc>(this.state, userID, WORKSPACE_ASSIGNMENTS, workspaceID, (raw) => {
436
+ const doc = assignmentsDoc(raw, workspaceID)
437
+ const at = this.now()
438
+ const assignments = doc?.assignments ?? []
439
+ let keys = (doc?.keys ?? []).filter((k) => at - k.at < IDEMPOTENCY_TTL_MS)
440
+ const held = key === undefined ? undefined : keys.find((k) => k.key === key)
441
+ if (held) {
442
+ if (held.digest !== digest)
443
+ throw new AgentError("conflict", `Idempotency-Key ${key} was used for a different assignment`, { hint: "Use a new key for a different body, or resend the original body" })
444
+ const existing = assignments.find((a) => a.id === held.assignmentID)
445
+ if (existing) {
446
+ result = { assignment: existing, created: false }
447
+ return undefined
448
+ }
449
+ keys = keys.filter((k) => k !== held)
450
+ }
451
+ if (assignments.filter((a) => !CLOSED.has(a.status)).length >= this.limits.openAssignmentsPerWorkspace)
452
+ throw new AgentError("rate_limited", `A workspace has at most ${this.limits.openAssignmentsPerWorkspace} open assignments`, { hint: "Close some first" })
453
+ const assignment: StoredAssignment = { v: 1, id: this.newID("assignment"), workspaceID, ...body, revision: 1, createdAt: at, updatedAt: at }
454
+ if (key !== undefined) keys = [...keys, { key, assignmentID: assignment.id, digest, at }].slice(-KEYS_MAX)
455
+ result = { assignment, created: true }
456
+ return { v: 1, workspaceID, assignments: [...assignments, assignment], keys }
457
+ })
458
+ return result!
459
+ }
460
+
461
+ /** One retained assignment; not_found otherwise. */
462
+ async assignment(userID: string, workspaceID: string, id: string): Promise<StoredAssignment> {
463
+ const found = (await this.readAssignments(userID, workspaceID)).find((a) => a.id === id)
464
+ if (!found) throw new AgentError("not_found", `No assignment ${id} in workspace ${workspaceID}`)
465
+ return found
466
+ }
467
+
468
+ /** Retained assignments by creation, paged and filtered, each with its assignee's membership and presence. */
469
+ async assignments(userID: string, workspaceID: string, query: AssignmentListQuery = {}): Promise<Page<AssignmentView>> {
470
+ const limit = query.limit ?? LIST_LIMIT_DEFAULT
471
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
472
+ if (query.status !== undefined && !ASSIGNMENT_STATUSES.includes(query.status as AssignmentStatus))
473
+ throw new AgentError("invalid", `status must be one of ${ASSIGNMENT_STATUSES.join(", ")}`)
474
+ const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor)
475
+ const all = (await this.readAssignments(userID, workspaceID))
476
+ .filter((a) => query.memberID === undefined || a.memberID === query.memberID)
477
+ .filter((a) => query.status === undefined || a.status === query.status)
478
+ .filter((a) => !after || a.createdAt > after[0] || (a.createdAt === after[0] && a.id > after[1]))
479
+ .sort(byCreation)
480
+ const page = all.slice(0, limit)
481
+ const members = new Map((await this.readMembers(userID, workspaceID)).map((m) => [m.id, m]))
482
+ const presence = await this.presenceOf(userID, page.flatMap((a) => members.get(a.memberID)?.sessionID ?? []))
483
+ const items = page.map((assignment): AssignmentView => {
484
+ const member = members.get(assignment.memberID)
485
+ return {
486
+ assignment,
487
+ member: !member ? "missing" : member.leftAt === undefined ? "current" : "left",
488
+ presence: member ? presence.get(member.sessionID)! : "unknown",
489
+ }
490
+ })
491
+ return all.length > limit ? { items, cursor: encodeCursor(page.at(-1)!) } : { items }
492
+ }
493
+
494
+ /** Moves an open assignment to another current member. The same member again is a no-op. */
495
+ async reassign(userID: string, workspaceID: string, id: string, memberID: unknown, expectedRevision?: number): Promise<StoredAssignment> {
496
+ checkExpected(expectedRevision)
497
+ if (!isContractID("member", memberID)) throw new AgentError("invalid", "memberID must be a workspace member ID")
498
+ await this.active(userID, workspaceID)
499
+ await this.currentMember(userID, workspaceID, memberID)
500
+ return this.mutateAssignment(userID, workspaceID, id, expectedRevision, (a) => {
501
+ if (CLOSED.has(a.status)) throw new AgentError("invalid", `Assignment ${id} is ${a.status}`, { hint: "Create a new assignment" })
502
+ return a.memberID === memberID ? undefined : { ...a, memberID }
503
+ })
504
+ }
505
+
506
+ /**
507
+ * Sets the status. `done` and `cancelled` are the explicit completion: a closed assignment is final (the same
508
+ * status again is a no-op, anything else invalid). Nothing else ever closes an assignment.
509
+ */
510
+ async setStatus(userID: string, workspaceID: string, id: string, status: unknown, expectedRevision?: number): Promise<StoredAssignment> {
511
+ checkExpected(expectedRevision)
512
+ if (!ASSIGNMENT_STATUSES.includes(status as AssignmentStatus)) throw new AgentError("invalid", `status must be one of ${ASSIGNMENT_STATUSES.join(", ")}`)
513
+ await this.active(userID, workspaceID)
514
+ return this.mutateAssignment(userID, workspaceID, id, expectedRevision, (a) => {
515
+ if (a.status === status) return undefined
516
+ if (CLOSED.has(a.status)) throw new AgentError("invalid", `Assignment ${id} is ${a.status}`, { hint: "Create a new assignment" })
517
+ const next = { ...a, status: status as AssignmentStatus }
518
+ return CLOSED.has(next.status) ? { ...next, closedAt: this.now() } : next
519
+ })
520
+ }
521
+
522
+ // ---------- internals ----------
523
+
524
+ private assignmentBody(input: unknown): Pick<StoredAssignment, "memberID" | "title" | "status" | "criteria" | "ref"> {
525
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
526
+ const { memberID, title, status, criteria, ref } = input
527
+ if (!isContractID("member", memberID)) throw new AgentError("invalid", "memberID must be a workspace member ID")
528
+ if (!isLabel(title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
529
+ if (status !== undefined && !OPENING.includes(status as AssignmentStatus))
530
+ throw new AgentError("invalid", `status must be one of ${OPENING.join(", ")}`, { hint: "Closing is an explicit status change after creation" })
531
+ const { criteriaPerAssignment, criterionMax, refMax } = this.limits
532
+ if (criteria !== undefined && (!Array.isArray(criteria) || criteria.length > criteriaPerAssignment || !criteria.every((c) => isLabel(c, criterionMax))))
533
+ throw new AgentError("invalid", `criteria must be a list of at most ${criteriaPerAssignment} lines of 1–${criterionMax} characters`)
534
+ if (ref !== undefined && !isLabel(ref, refMax)) throw new AgentError("invalid", `ref must be 1–${refMax} characters of text`)
535
+ return {
536
+ memberID,
537
+ title,
538
+ status: (status as AssignmentStatus | undefined) ?? "open",
539
+ ...(criteria !== undefined ? { criteria: [...(criteria as string[])] } : {}),
540
+ ...(ref !== undefined ? { ref: ref as string } : {}),
541
+ }
542
+ }
543
+
544
+ /** The user's active workspace (not_found; invalid when archived). */
545
+ private async active(userID: string, workspaceID: string): Promise<HubWorkspace> {
546
+ const workspace = await this.workspaces.require(userID, workspaceID)
547
+ if (workspace.state === "archived") throw archivedError(workspaceID)
548
+ return workspace
549
+ }
550
+
551
+ private async readMembers(userID: string, workspaceID: string): Promise<readonly WorkspaceMember[]> {
552
+ await this.workspaces.require(userID, workspaceID)
553
+ return membersDoc((await this.state.get(userID, WORKSPACE_MEMBERS, workspaceID))?.value, workspaceID)?.members ?? []
554
+ }
555
+
556
+ private async readAssignments(userID: string, workspaceID: string): Promise<readonly StoredAssignment[]> {
557
+ await this.workspaces.require(userID, workspaceID)
558
+ return assignmentsDoc((await this.state.get(userID, WORKSPACE_ASSIGNMENTS, workspaceID))?.value, workspaceID)?.assignments ?? []
559
+ }
560
+
561
+ /** A current member (not_found when missing, invalid when departed). */
562
+ private async currentMember(userID: string, workspaceID: string, memberID: string): Promise<WorkspaceMember> {
563
+ const member = await this.member(userID, workspaceID, memberID)
564
+ if (member.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`, { hint: "Assign a current member" })
565
+ return member
566
+ }
567
+
568
+ /** Members referenced by open assignments (never pruned). */
569
+ private async openAssignees(userID: string, workspaceID: string): Promise<Set<string>> {
570
+ const doc = assignmentsDoc((await this.state.get(userID, WORKSPACE_ASSIGNMENTS, workspaceID))?.value, workspaceID)
571
+ return new Set((doc?.assignments ?? []).filter((a) => !CLOSED.has(a.status)).map((a) => a.memberID))
572
+ }
573
+
574
+ private async presenceOf(userID: string, sessionIDs: readonly string[]): Promise<Map<string, SessionPresence>> {
575
+ const out = new Map<string, SessionPresence>()
576
+ for (const id of sessionIDs) if (!out.has(id)) out.set(id, await this.deps.presence(userID, id))
577
+ return out
578
+ }
579
+
580
+ /** Version-checked change of one member inside the aggregate; `change` returning undefined leaves it alone. */
581
+ private async mutateMember(
582
+ userID: string,
583
+ workspaceID: string,
584
+ memberID: string,
585
+ expectedRevision: number | undefined,
586
+ change: (current: WorkspaceMember) => WorkspaceMember | undefined,
587
+ ): Promise<WorkspaceMember> {
588
+ let result: WorkspaceMember | undefined
589
+ await updateVersioned<MembersDoc>(this.state, userID, WORKSPACE_MEMBERS, workspaceID, (raw) => {
590
+ const doc = membersDoc(raw, workspaceID)
591
+ const current = doc?.members.find((m) => m.id === memberID)
592
+ if (!doc || !current) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
593
+ if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Member ${memberID}`, current.revision)
594
+ const next = change(current)
595
+ result = next ? { ...next, revision: current.revision + 1 } : current
596
+ return next ? { ...doc, members: doc.members.map((m) => (m.id === memberID ? result! : m)) } : undefined
597
+ })
598
+ return result!
599
+ }
600
+
601
+ /** Version-checked change of one assignment, then closed-assignment retention. */
602
+ private async mutateAssignment(
603
+ userID: string,
604
+ workspaceID: string,
605
+ id: string,
606
+ expectedRevision: number | undefined,
607
+ change: (current: StoredAssignment) => StoredAssignment | undefined,
608
+ ): Promise<StoredAssignment> {
609
+ let result: StoredAssignment | undefined
610
+ await updateVersioned<AssignmentsDoc>(this.state, userID, WORKSPACE_ASSIGNMENTS, workspaceID, (raw) => {
611
+ const doc = assignmentsDoc(raw, workspaceID)
612
+ const current = doc?.assignments.find((a) => a.id === id)
613
+ if (!doc || !current) throw new AgentError("not_found", `No assignment ${id} in workspace ${workspaceID}`)
614
+ if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Assignment ${id}`, current.revision)
615
+ const next = change(current)
616
+ if (!next) {
617
+ result = current
618
+ return undefined
619
+ }
620
+ result = { ...next, revision: current.revision + 1, updatedAt: this.now() }
621
+ return { ...doc, assignments: this.retained(doc.assignments.map((a) => (a.id === id ? result! : a))) }
622
+ })
623
+ return result!
624
+ }
625
+
626
+ /** Drops the oldest closed assignments beyond closedAssignmentsRetained. */
627
+ private retained(assignments: readonly StoredAssignment[]): StoredAssignment[] {
628
+ const closed = assignments.filter((a) => CLOSED.has(a.status)).sort((a, b) => (a.closedAt ?? 0) - (b.closedAt ?? 0))
629
+ const drop = new Set(closed.slice(0, Math.max(0, closed.length - this.limits.closedAssignmentsRetained)).map((a) => a.id))
630
+ return assignments.filter((a) => !drop.has(a.id))
631
+ }
632
+ }