@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,910 @@
1
+ // Team staffing (HWA-35; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams and the
2
+ // HWA-33/34/05/15 clarifications). Binds live sessions to the roles of a team so the team outlives any one session:
3
+ // attach an existing session (any machine) or launch a new member per role through the launch API.
4
+ //
5
+ // Seats (TeamSeatStore, `team-seats`) stay the single source of team policy rights (HWA-34 reads them fresh per
6
+ // send). This service adds, in its own collection `team-staffing` (one aggregate per team, key: team ID):
7
+ // - the team's workspace link (`workspaceID`) and which workspace members it joined (`managed`: session → member);
8
+ // - staffing launch requests (`launching` → `staffed` | `failed`) and their Idempotency-Keys.
9
+ //
10
+ // Team ↔ workspace (decision recorded in Brain, project hub): ONE workspace per team, created at the team's first
11
+ // staffing (title = team title, metadata `{team: <team ID>}`) and linked here. Every session holding a current
12
+ // seat is one workspace member (role `worker`; one member per session even with several roles in the team). The
13
+ // entry role supervises: each non-entry member without a live supervision link is attached under the earliest
14
+ // staffed entry member (SupervisionStore, HWA-05). Existing links are never rewritten (a user's rearrangement
15
+ // stands); a departing entry member's links move to its replacement (memberDeparted replacedBy) or are detached and
16
+ // re-attached under another entry member. The workspace master is not set (HWA-12 owns explicit master
17
+ // replacement). A team archived workspace is left alone: staffing still works, membership sync pauses until the user
18
+ // restores it. Several teams → several workspaces, so a session's bindings in one team never move when it is staffed
19
+ // in another. Native parentID/rootID are never read or written here.
20
+ //
21
+ // Generations: re-staffing the same (role, session) is its next generation (TeamSeatStore). An explicit replacement
22
+ // ends the old seat and gives the new session the slot's next generation (old + 1) in ONE version-checked write on
23
+ // the seats aggregate, so the old member loses its team policy rights at that instant. Replacing by launch ends the
24
+ // old seat at once (the role is vacant while the launch runs) and the launched session takes the next generation.
25
+ //
26
+ // Presence: seats survive member restarts and disconnects; only deletion (sessionEnded, wired from
27
+ // Registry.onSessionDeleted) or an explicit unstaff/replace ends them. The roster reports each seat's presence
28
+ // (online/offline/archived/deleted/unknown), vacancy, understaffing (below minMembers), launching counts and the
29
+ // role profile's assignment and ack status as stored (HWA-15) — never assumed applied.
30
+ //
31
+ // Authority: team definitions and seats grant nothing. Users staff their own teams. Agents (apps and sessions) need
32
+ // the existing grants of their user, checked and spent in the acting order (kill switch → grants usable → actions
33
+ // spent → kill switch): attach and unstaff need a `roles` grant covering the session's project; a launch needs
34
+ // scope `sessions:launch` (apps), a `launch` and a `roles` grant covering the repo, and — when the role names a
35
+ // profile — scope `profiles:assign` (apps; sessions never pin profiles), exactly as POST /launches. A missing grant
36
+ // answers `needs_user` with `extra.missing` (verb + directory) and is audited (verb team.staff / team.unstaff); no
37
+ // grant is ever issued and no Decision is opened by staffing (an agent may ask through a grant request).
38
+ //
39
+ // Launch correlation: the launch carries labels `hub.team` and `hub.team.request` and the request ID as its launch
40
+ // key. A launch change staffs only when a stored `launching` request of that team has this launch (and launcher):
41
+ // labels alone never grant a seat. roster()/settle() re-check pending launches, so a change missed across a hub
42
+ // restart is picked up on the next read.
43
+ import { randomBytes } from "node:crypto"
44
+ import {
45
+ CONTRACT_VERSION,
46
+ ID_PREFIXES,
47
+ IDEMPOTENCY_KEY,
48
+ IDEMPOTENCY_TTL_MS,
49
+ type IdKind,
50
+ isExpectedRevision,
51
+ TEAM_LIMITS,
52
+ type ProfileAckStatus,
53
+ type ProfileAssignment,
54
+ } from "../../protocol"
55
+ import type { ActingSwitch } from "../agent/acting-switch"
56
+ import { auditOf } from "../agent/acting"
57
+ import type { AgentActor } from "../agent/actor"
58
+ import type { AgentAudit, AuditTarget } from "../agent/audit"
59
+ import { partyFromActor } from "../agent/envelope"
60
+ import { AgentError } from "../agent/errors"
61
+ import type { AgentEvents, EventInput } from "../agent/events"
62
+ import type { Grant, Grants } from "../agent/grants"
63
+ import { canonicalBody } from "../agent/idempotency"
64
+ import type { LaunchEngine } from "../agent/launch-engine"
65
+ import { launchedBy } from "../agent/launch-routes"
66
+ import { validateLaunchSpec } from "../agent/launch-spec"
67
+ import type { Launch } from "../agent/launch-store"
68
+ import { updateVersioned } from "../agent/versioned"
69
+ import type { Clock, StateStore } from "../backend/types"
70
+ import { memberJoinedEvent, memberLeftEvent, supervisionDepartedEvent, supervisionSetEvent, workspaceCreatedEvent } from "../workspaces/events"
71
+ import type { MembershipStore, SessionPresence, SessionPresenceLookup } from "../workspaces/members"
72
+ import type { WorkspaceStore } from "../workspaces/store"
73
+ import type { SupervisionStore } from "../workspaces/supervision"
74
+ import { readSeatsDoc, type SeatsDoc, TEAM_SEATS } from "./collections"
75
+ import { teamStaffedEvent, teamUnstaffedEvent } from "./events"
76
+ import type { TeamSeatRecord, TeamSeatStore } from "./seats"
77
+ import type { Team, TeamStore } from "./store"
78
+ import { TEAM_STORE_LIMITS, type TeamRoleSpec } from "./validation"
79
+
80
+ /** One aggregate per team (key: team ID): workspace link, managed members, launch requests, Idempotency-Keys. */
81
+ export const TEAM_STAFFING = "team-staffing"
82
+ /** Launch labels naming the team and the staffing request (never trusted alone). */
83
+ export const TEAM_LAUNCH_LABEL = "hub.team"
84
+ export const TEAM_REQUEST_LABEL = "hub.team.request"
85
+ /** Finished (staffed/failed) launch requests kept per team. */
86
+ export const FINISHED_REQUESTS_RETAINED = 50
87
+ /** Live launch Idempotency-Keys kept per team. */
88
+ const KEYS_PER_TEAM = 500
89
+ const LAUNCH_FIELDS = new Set(["role", "launch"])
90
+ const REPLACE_FIELDS = new Set(["expectedRevision", "sessionID", "launch"])
91
+
92
+ export type StaffingRequestState = "launching" | "staffed" | "failed"
93
+
94
+ /** A staffing launch: one new member for one role, launched through the launch API. */
95
+ export interface StaffingRequest {
96
+ readonly id: string
97
+ readonly teamID: string
98
+ readonly role: string
99
+ readonly state: StaffingRequestState
100
+ /** The launcher (`user`, `app:<token>`, `ses_…`), as the launch records it. */
101
+ readonly by: string
102
+ readonly launchID?: string
103
+ /** A replacement: the ended seat and its generation (the new seat gets the next one). */
104
+ readonly replaces?: { readonly staffingID: string; readonly generation: number }
105
+ readonly sessionID?: string
106
+ readonly machineID?: string
107
+ readonly staffingID?: string
108
+ readonly reason?: string
109
+ readonly createdAt: number
110
+ readonly updatedAt: number
111
+ }
112
+
113
+ interface RequestKey {
114
+ readonly requestID: string
115
+ readonly digest: string
116
+ readonly at: number
117
+ }
118
+
119
+ interface StaffingDoc {
120
+ readonly v: 1
121
+ readonly teamID: string
122
+ readonly workspaceID?: string
123
+ /** Workspace members this service joined, by session (only these are departed by it). */
124
+ readonly managed: Readonly<Record<string, string>>
125
+ readonly requests: readonly StaffingRequest[]
126
+ readonly keys: Readonly<Record<string, RequestKey>>
127
+ }
128
+
129
+ /** The team's workspace as staffing sees it. */
130
+ export interface TeamWorkspaceLink {
131
+ readonly id: string
132
+ readonly state: "active" | "archived" | "missing"
133
+ }
134
+
135
+ export interface SeatProfileStatus {
136
+ readonly requested: { readonly profileID: string; readonly version?: number }
137
+ readonly assigned?: { readonly profileID: string; readonly version: number }
138
+ /** The stored ack status of the session's assignment; `unassigned` when it has none. */
139
+ readonly status: ProfileAckStatus | "unassigned"
140
+ /** The assignment is the requested profile (and version, when the role pins one). */
141
+ readonly matches: boolean
142
+ }
143
+
144
+ export interface RosterSeat {
145
+ readonly staffing: TeamSeatRecord
146
+ readonly presence: SessionPresence
147
+ readonly memberID?: string
148
+ readonly supervisorMemberID?: string
149
+ readonly profile?: SeatProfileStatus
150
+ }
151
+
152
+ export interface RosterRole {
153
+ readonly role: string
154
+ readonly entry: boolean
155
+ readonly minMembers: number
156
+ readonly maxMembers: number
157
+ readonly seats: readonly RosterSeat[]
158
+ /** Launches still on their way to a session. */
159
+ readonly launching: number
160
+ readonly online: number
161
+ /** Seats whose session is not online now (offline, archived, deleted or unknown). */
162
+ readonly offline: number
163
+ readonly vacant: boolean
164
+ readonly understaffed: boolean
165
+ }
166
+
167
+ export interface TeamRoster {
168
+ readonly teamID: string
169
+ readonly version: number
170
+ readonly state: Team["state"]
171
+ readonly paused: boolean
172
+ readonly workspace?: TeamWorkspaceLink
173
+ readonly roles: readonly RosterRole[]
174
+ readonly requests: readonly StaffingRequest[]
175
+ }
176
+
177
+ export interface StaffResult {
178
+ readonly staffing: TeamSeatRecord
179
+ readonly created: boolean
180
+ readonly workspace?: TeamWorkspaceLink
181
+ }
182
+
183
+ export interface LaunchResult {
184
+ readonly request: StaffingRequest
185
+ readonly launch: Launch
186
+ readonly created: boolean
187
+ }
188
+
189
+ export interface ReplaceResult {
190
+ readonly ended: TeamSeatRecord
191
+ /** The replacement seat (attach). */
192
+ readonly staffing?: TeamSeatRecord
193
+ /** The replacement launch (launch). */
194
+ readonly request?: StaffingRequest
195
+ readonly launch?: Launch
196
+ }
197
+
198
+ export interface StaffingProfiles {
199
+ assignment(userID: string, sessionID: string): Promise<ProfileAssignment | undefined>
200
+ get(userID: string, id: string): Promise<{ readonly latestVersion: number } | undefined>
201
+ }
202
+
203
+ export interface TeamStaffingDeps {
204
+ readonly state: StateStore
205
+ readonly teams: TeamStore
206
+ readonly seats: TeamSeatStore
207
+ readonly workspaces: WorkspaceStore
208
+ readonly members: MembershipStore
209
+ readonly supervision: SupervisionStore
210
+ readonly presence: SessionPresenceLookup
211
+ /** The session's project directory now (grant scope); undefined when no spoke reports it. */
212
+ readonly sessionDirectory: (userID: string, sessionID: string) => string | undefined
213
+ readonly launches: Pick<LaunchEngine, "accept" | "view">
214
+ readonly grants: Pick<Grants, "usable" | "consume">
215
+ readonly acting: Pick<ActingSwitch, "ensureActing">
216
+ readonly audit?: Pick<AgentAudit, "record">
217
+ readonly profiles?: StaffingProfiles
218
+ readonly events?: Pick<AgentEvents, "append">
219
+ readonly report?: (cause: unknown) => void
220
+ readonly now?: Clock
221
+ readonly newID?: (kind: IdKind) => string
222
+ /**
223
+ * (FX-07) Whether the session is one of the user's Concierge sessions (current or past). Such a session is never
224
+ * staffed (attach, replace, a team launch), for anyone: the hub manages it. The routes guard this too.
225
+ */
226
+ readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
227
+ }
228
+
229
+ const conciergeRefusal = () => new AgentError("forbidden", "This does not apply to the Concierge: the hub manages it", { extra: { policy: "concierge" } })
230
+
231
+ interface Need {
232
+ readonly verb: "launch" | "roles"
233
+ readonly directory: string
234
+ }
235
+
236
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
237
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
238
+
239
+ function unknownField(input: Record<string, unknown>, allowed: ReadonlySet<string>): void {
240
+ const unknown = Object.keys(input).find((k) => !allowed.has(k))
241
+ if (unknown !== undefined) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(unknown) ? unknown : "(unprintable)"}`)
242
+ }
243
+
244
+ function readDoc(raw: unknown, teamID: string): StaffingDoc {
245
+ if (raw === undefined) return { v: 1, teamID, managed: {}, requests: [], keys: {} }
246
+ if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
247
+ throw new AgentError("unsupported", `The staffing of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: TEAM_STAFFING } })
248
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.teamID !== teamID || !Array.isArray(raw.requests)) throw new AgentError("internal", `Stored staffing of team ${teamID} is malformed`)
249
+ return {
250
+ v: 1,
251
+ teamID,
252
+ ...(typeof raw.workspaceID === "string" ? { workspaceID: raw.workspaceID } : {}),
253
+ managed: isObj(raw.managed) ? (raw.managed as Record<string, string>) : {},
254
+ requests: raw.requests as StaffingRequest[],
255
+ keys: isObj(raw.keys) ? (raw.keys as Record<string, RequestKey>) : {},
256
+ }
257
+ }
258
+
259
+ /** Current seats plus the newest retained ended ones (as TeamSeatStore keeps them). */
260
+ function retainedSeats(seats: readonly TeamSeatRecord[]): TeamSeatRecord[] {
261
+ const ended = seats.filter((s) => s.until !== undefined).sort((a, b) => b.until! - a.until! || (a.id < b.id ? 1 : -1))
262
+ const keep = new Set(ended.slice(0, TEAM_STORE_LIMITS.endedSeatsRetained).map((s) => s.id))
263
+ return seats.filter((s) => s.until === undefined || keep.has(s.id))
264
+ }
265
+
266
+ /** Pending requests plus the newest finished ones. */
267
+ function retainedRequests(requests: readonly StaffingRequest[]): StaffingRequest[] {
268
+ const finished = requests.filter((r) => r.state !== "launching").sort((a, b) => b.updatedAt - a.updatedAt)
269
+ const keep = new Set(finished.slice(0, FINISHED_REQUESTS_RETAINED).map((r) => r.id))
270
+ return requests.filter((r) => r.state === "launching" || keep.has(r.id))
271
+ }
272
+
273
+ function roleOf(team: Team, role: unknown): TeamRoleSpec {
274
+ if (typeof role !== "string") throw new AgentError("invalid", "role must be a role name of the team")
275
+ const spec = team.roles.find((r) => r.name === role)
276
+ if (!spec) throw new AgentError("invalid", `Team ${team.name} has no role ${role.slice(0, 40)}`)
277
+ return spec
278
+ }
279
+
280
+ /** New members need an active, unpaused team. */
281
+ function staffable(team: Team): void {
282
+ if (team.state === "archived") throw new AgentError("invalid", `Team ${team.id} is archived`, { hint: "Restore it first" })
283
+ if (team.paused) throw new AgentError("invalid", `Team ${team.id} is paused`, { hint: "Resume it first" })
284
+ }
285
+
286
+ const roleLimit = (role: TeamRoleSpec) => Math.min(role.maxMembers ?? TEAM_LIMITS.sessionsPerRole, TEAM_LIMITS.sessionsPerRole)
287
+
288
+ export class TeamStaffingService {
289
+ private readonly state: StateStore
290
+ private readonly now: Clock
291
+ private readonly newID: (kind: IdKind) => string
292
+ private readonly report: (cause: unknown) => void
293
+
294
+ constructor(private readonly deps: TeamStaffingDeps) {
295
+ this.state = deps.state
296
+ this.now = deps.now ?? Date.now
297
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
298
+ this.report = deps.report ?? ((cause) => console.error(`[hub] team staffing: ${message(cause)}`))
299
+ }
300
+
301
+ // ---------- staffing ----------
302
+
303
+ /** Staffs an existing session (any machine) in a role. The same current (role, session) returns its seat. */
304
+ async attach(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<StaffResult> {
305
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
306
+ const team = await this.deps.teams.require(userID, teamID)
307
+ staffable(team)
308
+ roleOf(team, input.role)
309
+ const sessionID = await this.knownSession(userID, input.sessionID)
310
+ const needs = actor.kind === "user" ? [] : [{ verb: "roles" as const, directory: this.directoryOf(userID, sessionID) }]
311
+ const result = await this.authorized(userID, actor, "team.staff", needs, { sessionID }, () => this.deps.seats.staff(userID, teamID, input, options))
312
+ if (result.created) await this.emit(userID, teamStaffedEvent(result.staffing))
313
+ const workspace = await this.sync(userID, teamID)
314
+ return { ...result, ...(workspace ? { workspace } : {}) }
315
+ }
316
+
317
+ /** Launches a new member for a role through the launch API; the running session is staffed. */
318
+ async launch(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<LaunchResult> {
319
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
320
+ unknownField(input, LAUNCH_FIELDS)
321
+ const key = options.idempotencyKey
322
+ if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
323
+ const team = await this.deps.teams.require(userID, teamID)
324
+ staffable(team)
325
+ const role = roleOf(team, input.role)
326
+ const by = launchedBy(actor)
327
+ const digest = canonicalBody({ role: role.name, launch: input.launch })
328
+ if (key !== undefined) {
329
+ const held = await this.byKey(userID, teamID, key, digest, by)
330
+ if (held) return held
331
+ }
332
+ return this.launchFor(userID, actor, team, role, input.launch, { ...(key !== undefined ? { key: { key, digest } } : {}) })
333
+ }
334
+
335
+ /**
336
+ * Explicit replacement of a current seat (under its expected revision) by an existing session (`sessionID`) or a
337
+ * launch (`launch`). The old seat ends at once; the new member gets the slot's next generation.
338
+ */
339
+ async replace(userID: string, actor: AgentActor, teamID: string, staffingID: string, input: unknown): Promise<ReplaceResult> {
340
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
341
+ unknownField(input, REPLACE_FIELDS)
342
+ const { expectedRevision } = input
343
+ if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision is required (the revision you read)")
344
+ if ((input.sessionID === undefined) === (input.launch === undefined)) throw new AgentError("invalid", "Name either sessionID (an existing session) or launch (a new one)")
345
+ const team = await this.deps.teams.require(userID, teamID)
346
+ staffable(team)
347
+ const old = (await this.deps.seats.list(userID, teamID)).find((s) => s.id === staffingID)
348
+ if (!old) throw new AgentError("not_found", `No staffing ${staffingID} in team ${teamID}`)
349
+ if (old.revision !== expectedRevision)
350
+ throw new AgentError("conflict", `Staffing ${staffingID} changed since you read it (revision ${old.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: old.revision } })
351
+ if (old.until !== undefined) throw new AgentError("invalid", `Staffing ${staffingID} already ended`, { hint: "Staff the role instead" })
352
+ const role = roleOf(team, old.role)
353
+
354
+ if (input.launch !== undefined) {
355
+ const out = await this.launchFor(userID, actor, team, role, input.launch, { replaces: { old, expectedRevision } })
356
+ return { ended: out.ended!, request: out.request, launch: out.launch }
357
+ }
358
+ const sessionID = await this.knownSession(userID, input.sessionID)
359
+ if (sessionID === old.sessionID) throw new AgentError("invalid", `Session ${sessionID} already holds this seat`)
360
+ const needs = actor.kind === "user" ? [] : [{ verb: "roles" as const, directory: this.directoryOf(userID, sessionID) }]
361
+ const { ended, staffing } = await this.authorized(userID, actor, "team.staff", needs, { sessionID }, () =>
362
+ this.writeSeat(userID, team, role, sessionID, { end: { staffingID, expectedRevision } }),
363
+ )
364
+ await this.emit(userID, teamUnstaffedEvent(ended!), teamStaffedEvent(staffing))
365
+ await this.sync(userID, teamID, new Map([[old.sessionID, sessionID]]))
366
+ return { ended: ended!, staffing }
367
+ }
368
+
369
+ /** Ends a seat under its expected revision (an ended seat: no-op). */
370
+ async unstaff(userID: string, actor: AgentActor, teamID: string, staffingID: string, expectedRevision: number): Promise<TeamSeatRecord> {
371
+ const seat = (await this.deps.seats.list(userID, teamID)).find((s) => s.id === staffingID)
372
+ if (!seat) throw new AgentError("not_found", `No staffing ${staffingID} in team ${teamID}`)
373
+ const needs = actor.kind === "user" ? [] : [{ verb: "roles" as const, directory: this.directoryOf(userID, seat.sessionID) }]
374
+ const ended = await this.authorized(userID, actor, "team.unstaff", needs, { sessionID: seat.sessionID }, () => this.deps.seats.unstaff(userID, teamID, staffingID, expectedRevision))
375
+ if (seat.until === undefined && ended.until !== undefined) await this.emit(userID, teamUnstaffedEvent(ended))
376
+ await this.sync(userID, teamID)
377
+ return ended
378
+ }
379
+
380
+ /** The session was deleted: its seats end in every team; workspaces and supervision follow. */
381
+ async sessionEnded(userID: string, sessionID: string): Promise<TeamSeatRecord[]> {
382
+ const ended = await this.deps.seats.sessionEnded(userID, sessionID)
383
+ await this.emit(userID, ...ended.map(teamUnstaffedEvent))
384
+ for (const teamID of new Set(ended.map((s) => s.teamID))) {
385
+ try {
386
+ await this.sync(userID, teamID)
387
+ } catch (cause) {
388
+ this.report(cause)
389
+ }
390
+ }
391
+ return ended
392
+ }
393
+
394
+ // ---------- launches ----------
395
+
396
+ /** A launch changed (LaunchFeed): staffs the running session of a matching request, or fails an ended one. */
397
+ async launchChanged(userID: string, launch: Launch): Promise<void> {
398
+ const teamID = launch.spec.labels?.[TEAM_LAUNCH_LABEL]
399
+ const requestID = launch.spec.labels?.[TEAM_REQUEST_LABEL]
400
+ if (teamID === undefined || requestID === undefined || launch.owner !== userID) return
401
+ const raw = (await this.state.get(userID, TEAM_STAFFING, teamID))?.value
402
+ if (raw === undefined) return
403
+ const request = readDoc(raw, teamID).requests.find((r) => r.id === requestID)
404
+ if (!request || request.state !== "launching") return
405
+ const ours = (request.launchID ?? launch.id) === launch.id && launch.spec.key === request.id && (launch.by ?? "user") === request.by
406
+ if (!ours) return
407
+ if (launch.state === "running" && launch.sessionID !== undefined) {
408
+ await this.staffLaunched(userID, request, launch)
409
+ } else if (launch.state === "ended") {
410
+ await this.finish(userID, teamID, request.id, { state: "failed", reason: launch.reason || "The launch ended before its session was staffed" })
411
+ }
412
+ }
413
+
414
+ /** Re-checks the team's pending launches (a change missed across a restart is picked up here). */
415
+ async settle(userID: string, teamID: string): Promise<void> {
416
+ const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
417
+ for (const request of doc.requests.filter((r) => r.state === "launching" && r.launchID !== undefined)) {
418
+ const launch = await this.deps.launches.view(userID, request.launchID!)
419
+ if (launch) await this.launchChanged(userID, launch)
420
+ else await this.finish(userID, teamID, request.id, { state: "failed", reason: "The launch is no longer known" })
421
+ }
422
+ }
423
+
424
+ // ---------- reads ----------
425
+
426
+ /** The team's workspace ID (undefined before its first staffing). */
427
+ async workspaceOf(userID: string, teamID: string): Promise<string | undefined> {
428
+ return readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID).workspaceID
429
+ }
430
+
431
+ /** Roles with their seats, presence, vacancy, launches and profile status; settles pending launches first. */
432
+ async roster(userID: string, teamID: string): Promise<TeamRoster> {
433
+ const team = await this.deps.teams.require(userID, teamID)
434
+ await this.settle(userID, teamID)
435
+ const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
436
+ const current = await this.deps.seats.list(userID, teamID, { current: true })
437
+ const workspace = await this.link(userID, doc.workspaceID)
438
+ const memberOf = new Map<string, string>()
439
+ const supervisorOf = new Map<string, string>()
440
+ if (workspace?.state === "active" || workspace?.state === "archived") {
441
+ for (const { member } of await this.deps.members.members(userID, workspace.id)) memberOf.set(member.sessionID, member.id)
442
+ for (const { link, member, supervisor } of (await this.deps.supervision.graph(userID, workspace.id)).links)
443
+ if (member === "current" && supervisor === "current") supervisorOf.set(link.memberID, link.supervisorMemberID)
444
+ }
445
+ const presence = new Map<string, SessionPresence>()
446
+ for (const sessionID of new Set(current.map((s) => s.sessionID))) presence.set(sessionID, await this.deps.presence(userID, sessionID))
447
+ const roles: RosterRole[] = []
448
+ for (const role of team.roles) {
449
+ const seats: RosterSeat[] = []
450
+ for (const staffing of current.filter((s) => s.role === role.name)) {
451
+ const memberID = memberOf.get(staffing.sessionID)
452
+ const supervisorMemberID = memberID !== undefined ? supervisorOf.get(memberID) : undefined
453
+ const profile = role.profile ? await this.profileStatus(userID, staffing.sessionID, role.profile) : undefined
454
+ seats.push({
455
+ staffing,
456
+ presence: presence.get(staffing.sessionID)!,
457
+ ...(memberID !== undefined ? { memberID } : {}),
458
+ ...(supervisorMemberID !== undefined ? { supervisorMemberID } : {}),
459
+ ...(profile ? { profile } : {}),
460
+ })
461
+ }
462
+ const online = seats.filter((s) => s.presence === "online").length
463
+ const minMembers = role.minMembers ?? 0
464
+ roles.push({
465
+ role: role.name,
466
+ entry: role.name === team.entryRole,
467
+ minMembers,
468
+ maxMembers: roleLimit(role),
469
+ seats,
470
+ launching: doc.requests.filter((r) => r.role === role.name && r.state === "launching").length,
471
+ online,
472
+ offline: seats.length - online,
473
+ vacant: seats.length === 0,
474
+ understaffed: seats.length < minMembers,
475
+ })
476
+ }
477
+ return {
478
+ teamID,
479
+ version: team.version,
480
+ state: team.state,
481
+ paused: team.paused === true,
482
+ ...(workspace ? { workspace } : {}),
483
+ roles,
484
+ requests: [...doc.requests].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1)),
485
+ }
486
+ }
487
+
488
+ // ---------- internals: launches ----------
489
+
490
+ private async launchFor(
491
+ userID: string,
492
+ actor: AgentActor,
493
+ team: Team,
494
+ role: TeamRoleSpec,
495
+ base: unknown,
496
+ options: { readonly key?: { readonly key: string; readonly digest: string }; readonly replaces?: { readonly old: TeamSeatRecord; readonly expectedRevision: number } },
497
+ ): Promise<LaunchResult & { ended?: TeamSeatRecord }> {
498
+ if (actor.kind === "app" && !actor.scopes.includes("sessions:launch")) throw new AgentError("forbidden", "Staffing by launch needs scope sessions:launch")
499
+ const requestID = `tsr_${randomBytes(12).toString("hex")}`
500
+ const spec = await this.launchSpec(userID, team, role, base, requestID)
501
+ if (spec.agentProfile !== undefined && actor.kind !== "user" && !(actor.kind === "app" && actor.scopes.includes("profiles:assign")))
502
+ throw new AgentError("forbidden", "A launch's agentProfile needs scope profiles:assign; sessions cannot pin profiles", { hint: "Ask the user to staff this role" })
503
+ const repo = (spec.workspace as { repo: string }).repo
504
+ if (!options.replaces) await this.checkRoom(userID, team, role)
505
+ const by = launchedBy(actor)
506
+ const needs: Need[] = actor.kind === "user" ? [] : [{ verb: "launch", directory: repo }, { verb: "roles", directory: repo }]
507
+ return this.authorized(userID, actor, "team.staff", needs, {}, async () => {
508
+ let ended: TeamSeatRecord | undefined
509
+ if (options.replaces) {
510
+ ended = await this.deps.seats.unstaff(userID, team.id, options.replaces.old.id, options.replaces.expectedRevision)
511
+ await this.emit(userID, teamUnstaffedEvent(ended))
512
+ await this.sync(userID, team.id)
513
+ }
514
+ const at = this.now()
515
+ const request: StaffingRequest = {
516
+ id: requestID,
517
+ teamID: team.id,
518
+ role: role.name,
519
+ state: "launching",
520
+ by,
521
+ ...(options.replaces ? { replaces: { staffingID: options.replaces.old.id, generation: options.replaces.old.generation } } : {}),
522
+ createdAt: at,
523
+ updatedAt: at,
524
+ }
525
+ await this.writeDoc(userID, team.id, (doc) => {
526
+ if (!options.replaces && doc.requests.filter((r) => r.role === role.name && r.state === "launching").length >= roleLimit(role))
527
+ throw new AgentError("rate_limited", `Role ${role.name} of team ${team.name} holds at most ${roleLimit(role)} sessions`, { extra: { limit: roleLimit(role) } })
528
+ const live = this.liveKeys(doc)
529
+ if (options.key && live[options.key.key])
530
+ throw new AgentError("rate_limited", `The staffing for Idempotency-Key ${options.key.key} is still being launched`, { extra: { retryAfter: 1 } })
531
+ const keys = options.key ? { ...live, [options.key.key]: { requestID, digest: options.key.digest, at } } : doc.keys
532
+ return { ...doc, requests: retainedRequests([...doc.requests, request]), keys: this.boundedKeys(keys) }
533
+ })
534
+ let accepted: { launch: Launch; created: boolean }
535
+ try {
536
+ accepted = await this.deps.launches.accept(userID, spec, { by })
537
+ } catch (cause) {
538
+ await this.finish(userID, team.id, requestID, { state: "failed", reason: message(cause) })
539
+ throw cause
540
+ }
541
+ const stored = await this.finish(userID, team.id, requestID, { launchID: accepted.launch.id })
542
+ if (accepted.launch.state === "running" || accepted.launch.state === "ended") await this.launchChanged(userID, accepted.launch)
543
+ return { request: stored ?? { ...request, launchID: accepted.launch.id }, launch: accepted.launch, created: true, ...(ended ? { ended } : {}) }
544
+ })
545
+ }
546
+
547
+ /** The launch body: the caller's, with the role's placement/harness/model/profile defaults and team labels. */
548
+ private async launchSpec(userID: string, team: Team, role: TeamRoleSpec, base: unknown, requestID: string): Promise<Record<string, unknown>> {
549
+ if (!isObj(base)) throw new AgentError("invalid", "launch must be a launch body (workspace, prompt, …)")
550
+ if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send an Idempotency-Key header instead" })
551
+ const labels = isObj(base.labels) ? base.labels : base.labels === undefined ? {} : undefined
552
+ if (!labels) throw new AgentError("invalid", "launch.labels must be an object")
553
+ if (TEAM_LAUNCH_LABEL in labels || TEAM_REQUEST_LABEL in labels) throw new AgentError("invalid", `launch.labels ${TEAM_LAUNCH_LABEL} and ${TEAM_REQUEST_LABEL} are set by staffing`)
554
+ const prefs = role.prefs ?? {}
555
+ const placement = isObj(base.placement) ? base.placement : base.placement === undefined ? {} : undefined
556
+ if (!placement) throw new AgentError("invalid", "launch.placement must be an object")
557
+ const machine = placement.machine ?? prefs.machineID
558
+ const harness = base.harness ?? prefs.harness
559
+ const model = base.model ?? prefs.model
560
+ const agentProfile = base.agentProfile ?? (role.profile ? await this.profileRef(userID, role.profile) : undefined)
561
+ const spec: Record<string, unknown> = {
562
+ ...base,
563
+ key: requestID,
564
+ ...(harness !== undefined ? { harness } : {}),
565
+ ...(model !== undefined ? { model } : {}),
566
+ ...(machine !== undefined || base.placement !== undefined ? { placement: { ...placement, ...(machine !== undefined ? { machine } : {}) } } : {}),
567
+ ...(agentProfile !== undefined ? { agentProfile } : {}),
568
+ labels: { ...labels, [TEAM_LAUNCH_LABEL]: team.id, [TEAM_REQUEST_LABEL]: requestID },
569
+ }
570
+ validateLaunchSpec(spec)
571
+ return spec
572
+ }
573
+
574
+ /** The role's profile as a launch pins it: its version, or the profile's latest. */
575
+ private async profileRef(userID: string, profile: NonNullable<TeamRoleSpec["profile"]>): Promise<{ profileID: string; version: number }> {
576
+ if (profile.version !== undefined) return { profileID: profile.profileID, version: profile.version }
577
+ const found = await this.deps.profiles?.get(userID, profile.profileID)
578
+ if (!found) throw new AgentError("invalid", `The role's profile ${profile.profileID} is not available`, { hint: "Fix the team's role profile, or name launch.agentProfile" })
579
+ return { profileID: profile.profileID, version: found.latestVersion }
580
+ }
581
+
582
+ /** 429 when the role's current seats and pending launches reach its maxMembers. */
583
+ private async checkRoom(userID: string, team: Team, role: TeamRoleSpec): Promise<void> {
584
+ const seats = (await this.deps.seats.list(userID, team.id, { current: true })).filter((s) => s.role === role.name).length
585
+ const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, team.id))?.value, team.id)
586
+ const launching = doc.requests.filter((r) => r.role === role.name && r.state === "launching").length
587
+ if (seats + launching >= roleLimit(role))
588
+ throw new AgentError("rate_limited", `Role ${role.name} of team ${team.name} holds at most ${roleLimit(role)} sessions (staffed and launching)`, {
589
+ hint: "Unstaff or replace a member instead",
590
+ extra: { limit: roleLimit(role) },
591
+ })
592
+ }
593
+
594
+ /** The request a live Idempotency-Key names (same launcher and body), with its launch. */
595
+ private async byKey(userID: string, teamID: string, key: string, digest: string, by: string): Promise<LaunchResult | undefined> {
596
+ const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
597
+ const held = this.liveKeys(doc)[key]
598
+ if (!held) return undefined
599
+ const request = doc.requests.find((r) => r.id === held.requestID)
600
+ if (!request) return undefined
601
+ if (request.by !== by) throw new AgentError("invalid", "This key was used by another launcher", { hint: "Use a key of your own" })
602
+ if (held.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for another staffing`, { hint: "Use a new key for a different body" })
603
+ const launch = request.launchID !== undefined ? await this.deps.launches.view(userID, request.launchID) : undefined
604
+ if (!launch) throw new AgentError("rate_limited", `The staffing for Idempotency-Key ${key} is still being launched`, { extra: { retryAfter: 1 } })
605
+ return { request, launch, created: false }
606
+ }
607
+
608
+ private async staffLaunched(userID: string, request: StaffingRequest, launch: Launch): Promise<void> {
609
+ const sessionID = launch.sessionID!
610
+ const where = { sessionID, ...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}) }
611
+ let staffing: TeamSeatRecord
612
+ let created: boolean
613
+ try {
614
+ await this.notConcierge(userID, sessionID)
615
+ const team = await this.deps.teams.require(userID, request.teamID)
616
+ if (request.replaces) {
617
+ staffable(team)
618
+ const out = await this.writeSeat(userID, team, roleOf(team, request.role), sessionID, { minGeneration: request.replaces.generation + 1 })
619
+ staffing = out.staffing
620
+ created = out.created
621
+ } else {
622
+ const out = await this.deps.seats.staff(userID, request.teamID, { role: request.role, sessionID })
623
+ staffing = out.staffing
624
+ created = out.created
625
+ }
626
+ } catch (cause) {
627
+ if (!(cause instanceof AgentError)) throw cause
628
+ await this.finish(userID, request.teamID, request.id, { state: "failed", reason: `The launched session was not staffed: ${cause.message}`, ...where })
629
+ return
630
+ }
631
+ const finished = await this.finish(userID, request.teamID, request.id, { state: "staffed", staffingID: staffing.id, ...where })
632
+ if (created && finished) await this.emit(userID, teamStaffedEvent(staffing))
633
+ await this.sync(userID, request.teamID)
634
+ }
635
+
636
+ /** Moves a launching request on (state, launch, session); undefined when it is no longer launching. */
637
+ private async finish(userID: string, teamID: string, requestID: string, change: Partial<StaffingRequest>): Promise<StaffingRequest | undefined> {
638
+ let out: StaffingRequest | undefined
639
+ await this.writeDoc(userID, teamID, (doc) => {
640
+ const request = doc.requests.find((r) => r.id === requestID)
641
+ if (!request || request.state !== "launching") return undefined
642
+ out = { ...request, ...change, updatedAt: this.now() }
643
+ return { ...doc, requests: retainedRequests(doc.requests.map((r) => (r.id === requestID ? out! : r))) }
644
+ })
645
+ return out
646
+ }
647
+
648
+ // ---------- internals: seats ----------
649
+
650
+ /**
651
+ * One version-checked write on the team's seats: optionally ends a seat (replacement) and staffs the session with
652
+ * at least `minGeneration` (and above its own earlier generations in the role). An existing current seat of the
653
+ * (role, session) is returned as is.
654
+ */
655
+ private async writeSeat(
656
+ userID: string,
657
+ team: Team,
658
+ role: TeamRoleSpec,
659
+ sessionID: string,
660
+ options: { readonly end?: { readonly staffingID: string; readonly expectedRevision: number }; readonly minGeneration?: number },
661
+ ): Promise<{ staffing: TeamSeatRecord; created: boolean; ended?: TeamSeatRecord }> {
662
+ let result: { staffing: TeamSeatRecord; created: boolean; ended?: TeamSeatRecord } | undefined
663
+ await updateVersioned<SeatsDoc>(this.state, userID, TEAM_SEATS, team.id, (raw) => {
664
+ const doc = readSeatsDoc(raw, team.id)
665
+ let seats = [...((doc?.seats ?? []) as TeamSeatRecord[])]
666
+ const at = this.now()
667
+ let ended: TeamSeatRecord | undefined
668
+ let floor = options.minGeneration ?? 1
669
+ if (options.end) {
670
+ const old = seats.find((s) => s.id === options.end!.staffingID)
671
+ if (!old) throw new AgentError("not_found", `No staffing ${options.end.staffingID} in team ${team.id}`)
672
+ if (old.revision !== options.end.expectedRevision)
673
+ throw new AgentError("conflict", `Staffing ${old.id} changed since you read it (revision ${old.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: old.revision } })
674
+ if (old.until !== undefined) throw new AgentError("invalid", `Staffing ${old.id} already ended`, { hint: "Staff the role instead" })
675
+ ended = { ...old, until: at, revision: old.revision + 1 }
676
+ seats = seats.map((s) => (s.id === old.id ? ended! : s))
677
+ floor = Math.max(floor, old.generation + 1)
678
+ }
679
+ const current = seats.filter((s) => s.until === undefined)
680
+ const same = current.find((s) => s.role === role.name && s.sessionID === sessionID)
681
+ if (same) {
682
+ if (ended) throw new AgentError("invalid", `Session ${sessionID} already holds role ${role.name}`, { hint: "Unstaff the other seat instead" })
683
+ result = { staffing: same, created: false }
684
+ return undefined
685
+ }
686
+ if (current.filter((s) => s.role === role.name).length >= roleLimit(role))
687
+ throw new AgentError("rate_limited", `Role ${role.name} of team ${team.name} holds at most ${roleLimit(role)} sessions`, { extra: { limit: roleLimit(role) } })
688
+ const generation = Math.max(floor, 1 + Math.max(0, ...seats.filter((s) => s.role === role.name && s.sessionID === sessionID).map((s) => s.generation)))
689
+ const staffing: TeamSeatRecord = { v: 1, id: this.newID("staffing"), teamID: team.id, role: role.name, sessionID, generation, since: at, revision: 1, teamVersion: team.version }
690
+ result = { staffing, created: true, ...(ended ? { ended } : {}) }
691
+ return { v: 1, teamID: team.id, seats: retainedSeats([...seats, staffing]), keys: doc?.keys ?? {} }
692
+ })
693
+ return result!
694
+ }
695
+
696
+ // ---------- internals: workspace and supervision ----------
697
+
698
+ /**
699
+ * Brings the team's workspace in line with its current seats: creates/links it on first staffing, joins seated
700
+ * sessions, departs sessions this service joined that hold no seat any more (links move to `replacements`), and
701
+ * attaches unsupervised non-entry members under the earliest entry member. Archived workspace: left alone.
702
+ */
703
+ private async sync(userID: string, teamID: string, replacements: ReadonlyMap<string, string> = new Map()): Promise<TeamWorkspaceLink | undefined> {
704
+ const team = await this.deps.teams.get(userID, teamID)
705
+ if (!team) return undefined
706
+ const current = await this.deps.seats.list(userID, teamID, { current: true })
707
+ const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
708
+ let link = await this.link(userID, doc.workspaceID)
709
+ if (!link || link.state === "missing") {
710
+ if (current.length === 0) return link
711
+ link = await this.createWorkspace(userID, team)
712
+ }
713
+ if (link.state !== "active") return link
714
+ const workspaceID = link.id
715
+ const seated = [...new Set(current.map((s) => s.sessionID))]
716
+ const entries = [...new Set(current.filter((s) => s.role === team.entryRole).map((s) => s.sessionID))]
717
+ const memberOf = new Map((await this.deps.members.members(userID, workspaceID)).map((m) => [m.member.sessionID, m.member.id]))
718
+ const added: Record<string, string> = {}
719
+ const removed = new Set<string>()
720
+ for (const sessionID of seated) {
721
+ if (memberOf.has(sessionID)) continue
722
+ try {
723
+ const { member, created } = await this.deps.members.join(userID, workspaceID, { sessionID, role: "worker" })
724
+ memberOf.set(sessionID, member.id)
725
+ if (created) {
726
+ added[sessionID] = member.id
727
+ await this.emit(userID, memberJoinedEvent(member))
728
+ }
729
+ } catch (cause) {
730
+ this.report(cause)
731
+ }
732
+ }
733
+ const seatedSet = new Set(seated)
734
+ for (const [sessionID, memberID] of Object.entries(doc.managed)) {
735
+ if (seatedSet.has(sessionID)) continue
736
+ removed.add(sessionID)
737
+ try {
738
+ const before = await this.deps.members.member(userID, workspaceID, memberID).catch(() => undefined)
739
+ const left = before ? await this.deps.members.leave(userID, workspaceID, memberID, { reason: "removed" }) : undefined
740
+ const next = replacements.get(sessionID)
741
+ const replacedBy = next !== undefined ? memberOf.get(next) : undefined
742
+ const departed = await this.deps.supervision.memberDeparted(userID, workspaceID, memberID, replacedBy !== undefined ? { replacedBy } : {})
743
+ if (left && before && left.revision !== before.revision) await this.emit(userID, memberLeftEvent(left))
744
+ if (departed.detached + departed.transferred + departed.pruned > 0) await this.emit(userID, supervisionDepartedEvent(memberID, departed))
745
+ memberOf.delete(sessionID)
746
+ } catch (cause) {
747
+ this.report(cause)
748
+ }
749
+ }
750
+ const supervisor = entries.map((s) => memberOf.get(s)).find((m) => m !== undefined)
751
+ if (supervisor !== undefined) {
752
+ const graph = await this.deps.supervision.graph(userID, workspaceID)
753
+ const supervised = new Set(graph.links.filter((l) => l.member === "current" && l.supervisor === "current").map((l) => l.link.memberID))
754
+ const entrySet = new Set(entries)
755
+ for (const sessionID of seated) {
756
+ const memberID = memberOf.get(sessionID)
757
+ if (entrySet.has(sessionID) || memberID === undefined || supervised.has(memberID)) continue
758
+ try {
759
+ const result = await this.deps.supervision.set(userID, workspaceID, { memberID, supervisorMemberID: supervisor })
760
+ if (result.change !== "unchanged") await this.emit(userID, supervisionSetEvent(memberID, result, result.link))
761
+ } catch (cause) {
762
+ this.report(cause)
763
+ }
764
+ }
765
+ }
766
+ if (Object.keys(added).length > 0 || removed.size > 0) {
767
+ await this.writeDoc(userID, teamID, (d) => {
768
+ const managed = { ...d.managed, ...added }
769
+ for (const sessionID of removed) delete managed[sessionID]
770
+ return { ...d, managed }
771
+ })
772
+ }
773
+ return link
774
+ }
775
+
776
+ private async createWorkspace(userID: string, team: Team): Promise<TeamWorkspaceLink> {
777
+ const body = { title: team.title, metadata: { team: team.id } }
778
+ let created: Awaited<ReturnType<WorkspaceStore["create"]>>
779
+ try {
780
+ created = await this.deps.workspaces.create(userID, body, { idempotencyKey: `team-workspace:${team.id}` })
781
+ } catch (cause) {
782
+ // The key was used with an older title (a crash before the link was written): create without it.
783
+ if (!(cause instanceof AgentError) || cause.code !== "conflict") throw cause
784
+ created = await this.deps.workspaces.create(userID, body)
785
+ }
786
+ let linked = created.workspace.id
787
+ await this.writeDoc(userID, team.id, (doc) => {
788
+ if (doc.workspaceID !== undefined && doc.workspaceID !== created.workspace.id) {
789
+ linked = doc.workspaceID
790
+ return undefined
791
+ }
792
+ return doc.workspaceID === created.workspace.id ? undefined : { ...doc, workspaceID: created.workspace.id }
793
+ })
794
+ if (linked === created.workspace.id && created.created) await this.emit(userID, workspaceCreatedEvent(created.workspace))
795
+ return (await this.link(userID, linked)) ?? { id: linked, state: "missing" }
796
+ }
797
+
798
+ private async link(userID: string, workspaceID: string | undefined): Promise<TeamWorkspaceLink | undefined> {
799
+ if (workspaceID === undefined) return undefined
800
+ const workspace = await this.deps.workspaces.get(userID, workspaceID)
801
+ return { id: workspaceID, state: workspace ? workspace.state : "missing" }
802
+ }
803
+
804
+ // ---------- internals: authority, profiles, storage ----------
805
+
806
+ /** A session the user's directory knows (unknown/deleted: not_found); returns its ID. */
807
+ private async knownSession(userID: string, sessionID: unknown): Promise<string> {
808
+ if (typeof sessionID !== "string" || sessionID.length === 0 || sessionID.length > 200) throw new AgentError("invalid", "sessionID must be a session ID")
809
+ const presence = await this.deps.presence(userID, sessionID)
810
+ if (presence === "unknown" || presence === "deleted") throw new AgentError("not_found", `No session ${sessionID}`)
811
+ await this.notConcierge(userID, sessionID)
812
+ return sessionID
813
+ }
814
+
815
+ /**
816
+ * (FX-07) Refuses a Concierge session. A failed identity read refuses too (fail closed): a timed-out one with its own
817
+ * `busy` (503), any other as the Concierge refusal.
818
+ */
819
+ private async notConcierge(userID: string, sessionID: string): Promise<void> {
820
+ if (!this.deps.protects) return
821
+ const concierge = await this.deps.protects(userID, sessionID).catch((cause: unknown) => {
822
+ if (cause instanceof AgentError && cause.code === "busy") throw cause
823
+ return true
824
+ })
825
+ if (concierge) throw conciergeRefusal()
826
+ }
827
+
828
+ /** The session's project for a grant check; offline when no spoke reports it (as role writes answer). */
829
+ private directoryOf(userID: string, sessionID: string): string {
830
+ const directory = this.deps.sessionDirectory(userID, sessionID)
831
+ if (directory === undefined) throw new AgentError("offline", "No spoke reports this session now, so its project cannot be checked against a grant")
832
+ return directory
833
+ }
834
+
835
+ /**
836
+ * Runs `run` for the caller. Agents: kill switch → every needed grant usable (else needs_user listing the missing
837
+ * ones; nothing issued, nothing escalated) → one action spent per grant → kill switch again. Audited.
838
+ */
839
+ private async authorized<T>(userID: string, actor: AgentActor, verb: string, needs: readonly Need[], target: AuditTarget, run: () => Promise<T>): Promise<T> {
840
+ const used: Grant[] = []
841
+ const record = async (decision: ReturnType<typeof auditOf>[0], outcome: ReturnType<typeof auditOf>[1], reason?: string) => {
842
+ await this.deps.audit
843
+ ?.record(userID, { actor: partyFromActor(actor), verb, target, ...(used[0] ? { grant: used[0].id } : {}), decision, ...(reason ? { reason } : {}), outcome })
844
+ .catch(this.report)
845
+ }
846
+ try {
847
+ if (actor.kind !== "user") {
848
+ this.deps.acting.ensureActing(userID)
849
+ const missing: Need[] = []
850
+ const found: Grant[] = []
851
+ for (const need of needs) {
852
+ const usable = await this.deps.grants.usable(actor, need.verb, { directory: need.directory })
853
+ if (usable.length === 0) missing.push(need)
854
+ else found.push(usable[0]!)
855
+ }
856
+ if (missing.length > 0)
857
+ throw new AgentError("needs_user", `Needs the user: no grant lets this ${actor.kind} ${missing.map((m) => `${m.verb} in ${m.directory}`).join(" or ")}`, {
858
+ hint: "The user staffs this role, or grants the missing verbs (an agent may ask with a grant request); staffing never issues grants",
859
+ extra: { missing },
860
+ })
861
+ for (const grant of found) {
862
+ await this.deps.grants.consume(grant)
863
+ used.push(grant)
864
+ }
865
+ this.deps.acting.ensureActing(userID)
866
+ }
867
+ const result = await run()
868
+ await record("allow", "ok")
869
+ return result
870
+ } catch (cause) {
871
+ const [decision, outcome] = auditOf(cause)
872
+ await record(decision, outcome, message(cause))
873
+ throw cause
874
+ }
875
+ }
876
+
877
+ private async profileStatus(userID: string, sessionID: string, requested: NonNullable<TeamRoleSpec["profile"]>): Promise<SeatProfileStatus> {
878
+ const want = { profileID: requested.profileID, ...(requested.version !== undefined ? { version: requested.version } : {}) }
879
+ const assigned = await this.deps.profiles?.assignment(userID, sessionID)
880
+ if (!assigned) return { requested: want, status: "unassigned", matches: false }
881
+ return {
882
+ requested: want,
883
+ assigned: { profileID: assigned.profileID, version: assigned.version },
884
+ status: assigned.ack?.status ?? "pending",
885
+ matches: assigned.profileID === requested.profileID && (requested.version === undefined || requested.version === assigned.version),
886
+ }
887
+ }
888
+
889
+ private async writeDoc(userID: string, teamID: string, change: (doc: StaffingDoc) => StaffingDoc | undefined): Promise<void> {
890
+ await updateVersioned<StaffingDoc>(this.state, userID, TEAM_STAFFING, teamID, (raw) => change(readDoc(raw, teamID)))
891
+ }
892
+
893
+ private liveKeys(doc: StaffingDoc): Record<string, RequestKey> {
894
+ const now = this.now()
895
+ const out: Record<string, RequestKey> = {}
896
+ for (const [k, v] of Object.entries(doc.keys)) if (isObj(v) && typeof v.at === "number" && now - v.at < IDEMPOTENCY_TTL_MS) out[k] = v
897
+ return out
898
+ }
899
+
900
+ private boundedKeys(keys: Readonly<Record<string, RequestKey>>): Record<string, RequestKey> {
901
+ const entries = Object.entries(keys)
902
+ if (entries.length <= KEYS_PER_TEAM) return { ...keys }
903
+ return Object.fromEntries(entries.sort((a, b) => b[1].at - a[1].at).slice(0, KEYS_PER_TEAM))
904
+ }
905
+
906
+ private async emit(userID: string, ...events: EventInput[]): Promise<void> {
907
+ if (!this.deps.events) return
908
+ for (const event of events) await this.deps.events.append(userID, event).catch(this.report)
909
+ }
910
+ }