@huynle/hub 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,516 @@
1
+ // Team routes on /api/agent (HWA-37; contract: Teams in docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md
2
+ // and the HWA-33/34/35/36 clarifications). Intake (/teams/:id/intake…) lives in intake-routes.ts (HWA-36).
3
+ //
4
+ // POST /teams {name, title, mode, roles, entryRole, edges?, …} Idempotency-Key M
5
+ // GET /teams?state&tag…&project&metadata=k=v…&paused&limit&cursor R
6
+ // GET /teams/:id → {team, draft?} (:id is the team ID or its name) R
7
+ // PATCH /teams/:id {expectedRevision, publish?, …spec fields} → draft saved M
8
+ // GET /teams/:id/draft → {draft} R
9
+ // DELETE /teams/:id/draft?expectedRevision= discard the draft M
10
+ // PUT /teams/:id/draft/roles/:role {expectedRevision, label?, description?, …} add/replace M
11
+ // DELETE /teams/:id/draft/roles/:role?expectedRevision= (and its edges) M
12
+ // PUT /teams/:id/draft/edges {expectedRevision, from, to, kinds, direction} M
13
+ // DELETE /teams/:id/draft/edges?from&to&expectedRevision= M
14
+ // POST /teams/:id/publish {expectedRevision} → {team, published} M
15
+ // POST /teams/:id/{pause,resume,archive,restore} {expectedRevision} → {team} M
16
+ // GET /teams/:id/versions?limit&cursor retained published versions, newest first R
17
+ // GET /teams/:id/versions/:version → {version} (the frozen spec) R
18
+ // GET /teams/:id/diff?from&to from (default: published) → to (version or draft, default) R
19
+ // GET /teams/:id/staffing?ended=1 seats (current; with ended ones on ended=1) R + S
20
+ // PUT /teams/:id/staffing {role, sessionID} Idempotency-Key → {staffing, workspace?} M + S
21
+ // POST /teams/:id/staffing/launch {role, launch} Idempotency-Key → {request, launch} M + S
22
+ // POST /teams/:id/staffing/:staffing/replace {expectedRevision, sessionID | launch} M + S
23
+ // DELETE /teams/:id/staffing/:staffing?expectedRevision= M + S
24
+ // GET /teams/:id/roster → {roster} R + S
25
+ // GET /teams/:id/status → {team, roster, intake} (intake: counts only) R + S
26
+ // GET /team-settings → {settings: {teamIsolation, revision, updatedAt?}} users only
27
+ // PUT /team-settings {teamIsolation, expectedRevision} → {settings} users only
28
+ //
29
+ // The owner settings (RP-09) live outside /teams/ so a team named `settings` keeps its address; users only: app and
30
+ // session tokens never read or change them (isolation narrows what team members see; a member must not widen it).
31
+ // Who. Users: their own teams (another user's, by ID or name, is not_found). App tokens: R needs teams:read or
32
+ // teams:manage, M needs teams:manage; routes that show or name sessions (S: seats, roster, status, staffing) also
33
+ // need sessions:read (as the workspace routes). No existing token gains anything: the scopes are new. Session
34
+ // tokens: never change definitions (a team member editing its own team's policy would bypass it); they read only
35
+ // the teams they hold a current seat in (others are not_found), and may staff under the existing grants exactly as
36
+ // TeamStaffingService checks them (needs_user when missing; no grant or Decision is created here). Decision
37
+ // answers and grant administration stay console-only: no route here touches them.
38
+ // Every mutation of an existing record requires expectedRevision (409 conflict with currentRevision when stale);
39
+ // creates take Idempotency-Key (store-level: TeamStore and TeamSeatStore keep the key). Changes are audited (IDs
40
+ // only) and journaled with the compact team events (events.ts). Lists page (limit ≤ LIST_LIMIT_MAX); seats are
41
+ // capped per role and the ended ones retained per team.
42
+ import { boundedText } from "../agent/body"
43
+ import { isContractID, isExpectedRevision, isSlug, LIST_LIMIT_MAX } from "../../protocol"
44
+ import type { AgentContext, AgentRoute } from "../agent/routes"
45
+ import type { AgentEvents, EventInput } from "../agent/events"
46
+ import { AgentError } from "../agent/errors"
47
+ import { idempotencyKeyOf, storeAnswer } from "../agent/idempotency"
48
+ import type { Store } from "../store"
49
+ import { removeEdge, removeRole, setEdge, setRole } from "./draft-edits"
50
+ import { teamArchivedEvent, teamCreatedEvent, teamUpdatedEvent } from "./events"
51
+ import type { TeamIntakeService } from "./intake"
52
+ import type { TeamSeatStore } from "./seats"
53
+ import type { TeamStaffingService } from "./staffing"
54
+ import type { Team, TeamListQuery, TeamStore } from "./store"
55
+ import type { TeamSpec } from "./validation"
56
+
57
+ const PREFIX = "/api/agent/teams"
58
+ const SETTINGS_PATH = "/api/agent/team-settings"
59
+ /** Users only (no app or session token). */
60
+ const USERS = {} as const
61
+ const NO_STORE = { "cache-control": "no-store" }
62
+ /** Request bodies: a full team spec (32 roles, 256 edges, descriptions) stays well below this. */
63
+ export const TEAM_BODY_MAX = 256 * 1024
64
+ const MANAGE = { scope: "teams:manage" } as const
65
+ const READ = { app: true, session: true } as const
66
+ const STAFF = { scope: "teams:manage", session: true } as const
67
+
68
+ export interface TeamRoutesDeps {
69
+ readonly teams: TeamStore
70
+ readonly seats: TeamSeatStore
71
+ readonly staffing: TeamStaffingService
72
+ /** Intake counts in team status (HWA-36); omitted when absent. */
73
+ readonly intake?: Pick<TeamIntakeService, "summary">
74
+ readonly events?: Pick<AgentEvents, "append">
75
+ readonly audit?: Pick<Store, "audit">
76
+ }
77
+
78
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
79
+ const printable = (v: string) => (/^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : "(unprintable)")
80
+
81
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
82
+ const declared = Number(request.headers.get("content-length") ?? "0")
83
+ if (declared > TEAM_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${TEAM_BODY_MAX} bytes`)
84
+ const text = await boundedText(request, TEAM_BODY_MAX)
85
+ if (text.length > TEAM_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${TEAM_BODY_MAX} bytes`)
86
+ let raw: unknown
87
+ try {
88
+ raw = JSON.parse(text === "" ? "{}" : text)
89
+ } catch {
90
+ throw new AgentError("invalid", "Body must be JSON")
91
+ }
92
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
93
+ return raw as Record<string, unknown>
94
+ }
95
+
96
+ function only(body: Record<string, unknown>, allowed: readonly string[]): void {
97
+ for (const key of Object.keys(body)) if (!allowed.includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
98
+ }
99
+
100
+ /** The required expectedRevision of a mutation (body field or query parameter). */
101
+ function expected(value: unknown): number {
102
+ const n = typeof value === "string" && /^[0-9]{1,15}$/.test(value) ? Number(value) : value
103
+ if (n === undefined || n === null || n === "") throw new AgentError("invalid", "expectedRevision is required: the revision you read", { hint: "Read the team and send its revision" })
104
+ if (!isExpectedRevision(n)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
105
+ return n as number
106
+ }
107
+
108
+ function intParam(url: URL, name: string): number | undefined {
109
+ const raw = url.searchParams.get(name)
110
+ return raw === null ? undefined : /^-?[0-9]{1,12}$/.test(raw) ? Number(raw) : Number.NaN
111
+ }
112
+
113
+ const text = (url: URL, name: string) => url.searchParams.get(name) || undefined
114
+
115
+ function boolParam(url: URL, name: string): boolean | undefined {
116
+ const raw = url.searchParams.get(name)
117
+ if (raw === null || raw === "") return undefined
118
+ if (raw === "true" || raw === "1") return true
119
+ if (raw === "false" || raw === "0") return false
120
+ throw new AgentError("invalid", `${name} must be true or false`)
121
+ }
122
+
123
+ /** The list query: repeated `tag`, repeated `metadata=key=value`, state, project, paused, limit, cursor. */
124
+ function listQuery(url: URL): TeamListQuery {
125
+ const tags = url.searchParams.getAll("tag")
126
+ const pairs = url.searchParams.getAll("metadata")
127
+ const metadata: Record<string, string> = {}
128
+ for (const pair of pairs) {
129
+ const at = pair.indexOf("=")
130
+ if (at <= 0) throw new AgentError("invalid", "metadata filters are key=value")
131
+ metadata[pair.slice(0, at)] = pair.slice(at + 1)
132
+ }
133
+ const limit = intParam(url, "limit")
134
+ const paused = boolParam(url, "paused")
135
+ const state = text(url, "state")
136
+ const project = text(url, "project")
137
+ const cursor = text(url, "cursor")
138
+ return {
139
+ ...(state ? { state } : {}),
140
+ ...(tags.length ? { tags } : {}),
141
+ ...(project ? { project } : {}),
142
+ ...(pairs.length ? { metadata } : {}),
143
+ ...(paused !== undefined ? { paused } : {}),
144
+ ...(limit !== undefined ? { limit } : {}),
145
+ ...(cursor ? { cursor } : {}),
146
+ }
147
+ }
148
+
149
+ /** Apps reading teams need teams:read or teams:manage, plus sessions:read where sessions are shown. */
150
+ function appMayRead(context: AgentContext, sessions: boolean): void {
151
+ const { actor } = context
152
+ if (actor.kind !== "app") return
153
+ if (!actor.scopes.includes("teams:read") && !actor.scopes.includes("teams:manage")) throw new AgentError("forbidden", "Reading teams needs scope teams:read (or teams:manage)")
154
+ if (sessions && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Seats, roster, status and staffing also need scope sessions:read")
155
+ }
156
+
157
+ function appMayStaff(context: AgentContext): void {
158
+ const { actor } = context
159
+ if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Staffing also needs scope sessions:read")
160
+ }
161
+
162
+ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
163
+ const { teams, seats, staffing } = deps
164
+
165
+ async function changed(context: AgentContext, action: string, target: string, ...events: EventInput[]): Promise<void> {
166
+ await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
167
+ for (const event of events) await deps.events?.append(context.user.id, event)
168
+ }
169
+
170
+ /** The user's team by ID or name (another user's, or none: not_found). */
171
+ async function resolve(userID: string, raw: string): Promise<Team> {
172
+ const team = isContractID("team", raw) ? await teams.get(userID, raw) : isSlug(raw) ? await teams.byName(userID, raw) : undefined
173
+ if (!team) throw new AgentError("not_found", `No team ${printable(raw)}`)
174
+ return team
175
+ }
176
+
177
+ /** A team the caller may read: sessions only the teams they hold a current seat in. */
178
+ async function readable(context: AgentContext, sessions = false): Promise<Team> {
179
+ appMayRead(context, sessions)
180
+ const team = await resolve(context.user.id, context.params.id!)
181
+ const { actor } = context
182
+ if (actor.kind === "session" && !(await seats.seatsOf(context.user.id, actor.sessionID)).some((s) => s.teamID === team.id)) throw new AgentError("not_found", `No team ${printable(context.params.id!)}`)
183
+ return team
184
+ }
185
+
186
+ const managed = (context: AgentContext) => resolve(context.user.id, context.params.id!)
187
+
188
+ /** A status change under expectedRevision; journaled only when it changed something. */
189
+ const statusRoute = (verb: "pause" | "resume" | "archive" | "restore"): AgentRoute => ({
190
+ method: "POST",
191
+ path: `${PREFIX}/:id/${verb}`,
192
+ allow: MANAGE,
193
+ async handle(context) {
194
+ const body = await bodyOf(context.request)
195
+ only(body, ["expectedRevision"])
196
+ const revision = expected(body.expectedRevision)
197
+ const { id } = await managed(context)
198
+ const team = await teams[verb](context.user.id, id, revision)
199
+ if (team.revision !== revision) {
200
+ const event = verb === "archive" || verb === "restore" ? teamArchivedEvent(team) : teamUpdatedEvent(team, verb === "pause" ? "paused" : "resumed")
201
+ await changed(context, `team.${verb}`, team.id, event)
202
+ }
203
+ return ok({ team })
204
+ },
205
+ })
206
+
207
+ /** Saves a draft edit computed from the current draft (or published) spec, under expectedRevision. */
208
+ async function draftEdit(context: AgentContext, revision: number, edit: (spec: TeamSpec) => unknown): Promise<Response> {
209
+ const current = await managed(context)
210
+ if (current.revision !== revision)
211
+ throw new AgentError("conflict", `Team ${current.id} changed since you read it (revision ${current.revision} now)`, { hint: "Read it again and retry with its current revision", extra: { currentRevision: current.revision } })
212
+ const base = (await teams.draft(context.user.id, current.id))?.spec ?? (await teams.version(context.user.id, current.id, current.version))?.spec
213
+ if (!base) throw new AgentError("internal", `Team ${current.id} has no readable published version`)
214
+ const team = await teams.saveDraft(context.user.id, current.id, edit(base), revision)
215
+ await changed(context, "team.draft", team.id, teamUpdatedEvent(team, "draft"))
216
+ return ok({ team, draft: await teams.draft(context.user.id, team.id) })
217
+ }
218
+
219
+ /** A publish journals `published`, or `draft_discarded` when the draft equalled the published spec. */
220
+ async function publishedEvent(context: AgentContext, team: Team, published: boolean): Promise<void> {
221
+ await changed(context, published ? "team.publish" : "team.draft.discard", team.id, teamUpdatedEvent(team, published ? "published" : "draft_discarded"))
222
+ }
223
+
224
+ return [
225
+ // ---------- owner settings (users only) ----------
226
+ {
227
+ method: "GET",
228
+ path: SETTINGS_PATH,
229
+ allow: USERS,
230
+ async handle(context) {
231
+ return ok({ settings: await teams.settings(context.user.id) })
232
+ },
233
+ },
234
+ {
235
+ method: "PUT",
236
+ path: SETTINGS_PATH,
237
+ allow: USERS,
238
+ async handle(context) {
239
+ const { expectedRevision, ...input } = await bodyOf(context.request)
240
+ const settings = await teams.setSettings(context.user.id, input, expected(expectedRevision))
241
+ await changed(context, "team.settings", "team-settings")
242
+ return ok({ settings })
243
+ },
244
+ },
245
+ // ---------- definitions ----------
246
+ {
247
+ method: "POST",
248
+ path: PREFIX,
249
+ allow: MANAGE,
250
+ async handle(context) {
251
+ const body = await bodyOf(context.request)
252
+ const key = idempotencyKeyOf(context.request)
253
+ const { team, created } = await teams.create(context.user.id, body, key === undefined ? {} : { idempotencyKey: key })
254
+ if (created) await changed(context, "team.create", team.id, teamCreatedEvent(team))
255
+ return storeAnswer(created, { team })
256
+ },
257
+ },
258
+ {
259
+ method: "GET",
260
+ path: PREFIX,
261
+ allow: READ,
262
+ async handle(context) {
263
+ appMayRead(context, false)
264
+ const query = listQuery(context.url)
265
+ const { actor, user } = context
266
+ if (actor.kind !== "session") return ok(await teams.list(user.id, query))
267
+ // Sessions: only the teams they are seated in (a handful), unpaged.
268
+ const mine = new Set((await seats.seatsOf(user.id, actor.sessionID)).map((s) => s.teamID))
269
+ const items: Team[] = []
270
+ let cursor: string | undefined
271
+ do {
272
+ const page = await teams.list(user.id, { ...query, limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
273
+ items.push(...page.items.filter((t) => mine.has(t.id)))
274
+ cursor = page.cursor
275
+ } while (cursor)
276
+ return ok({ items })
277
+ },
278
+ },
279
+ {
280
+ method: "GET",
281
+ path: `${PREFIX}/:id`,
282
+ allow: READ,
283
+ async handle(context) {
284
+ const team = await readable(context)
285
+ const draft = await teams.draft(context.user.id, team.id)
286
+ return ok({ team, ...(draft ? { draft } : {}) })
287
+ },
288
+ },
289
+ {
290
+ method: "PATCH",
291
+ path: `${PREFIX}/:id`,
292
+ allow: MANAGE,
293
+ async handle(context) {
294
+ const { expectedRevision, publish, ...patch } = await bodyOf(context.request)
295
+ if (publish !== undefined && typeof publish !== "boolean") throw new AgentError("invalid", "publish must be true or false")
296
+ const revision = expected(expectedRevision)
297
+ const { id } = await managed(context)
298
+ const team = await teams.saveDraft(context.user.id, id, patch, revision)
299
+ await changed(context, "team.draft", team.id, teamUpdatedEvent(team, "draft"))
300
+ if (publish !== true) return ok({ team })
301
+ const result = await teams.publish(context.user.id, id, team.revision)
302
+ await publishedEvent(context, result.team, result.published)
303
+ return ok(result)
304
+ },
305
+ },
306
+ {
307
+ method: "GET",
308
+ path: `${PREFIX}/:id/draft`,
309
+ allow: READ,
310
+ async handle(context) {
311
+ const team = await readable(context)
312
+ const draft = await teams.draft(context.user.id, team.id)
313
+ if (!draft) throw new AgentError("not_found", `Team ${team.id} has no draft`)
314
+ return ok({ draft })
315
+ },
316
+ },
317
+ {
318
+ method: "DELETE",
319
+ path: `${PREFIX}/:id/draft`,
320
+ allow: MANAGE,
321
+ async handle(context) {
322
+ const revision = expected(text(context.url, "expectedRevision"))
323
+ const { id } = await managed(context)
324
+ const team = await teams.discardDraft(context.user.id, id, revision)
325
+ await changed(context, "team.draft.discard", team.id, teamUpdatedEvent(team, "draft_discarded"))
326
+ return ok({ team })
327
+ },
328
+ },
329
+ {
330
+ method: "PUT",
331
+ path: `${PREFIX}/:id/draft/roles/:role`,
332
+ allow: MANAGE,
333
+ async handle(context) {
334
+ const { expectedRevision, ...fields } = await bodyOf(context.request)
335
+ return draftEdit(context, expected(expectedRevision), (spec) => setRole(spec, context.params.role!, fields))
336
+ },
337
+ },
338
+ {
339
+ method: "DELETE",
340
+ path: `${PREFIX}/:id/draft/roles/:role`,
341
+ allow: MANAGE,
342
+ async handle(context) {
343
+ return draftEdit(context, expected(text(context.url, "expectedRevision")), (spec) => removeRole(spec, context.params.role!))
344
+ },
345
+ },
346
+ {
347
+ method: "PUT",
348
+ path: `${PREFIX}/:id/draft/edges`,
349
+ allow: MANAGE,
350
+ async handle(context) {
351
+ const { expectedRevision, ...edge } = await bodyOf(context.request)
352
+ return draftEdit(context, expected(expectedRevision), (spec) => setEdge(spec, edge))
353
+ },
354
+ },
355
+ {
356
+ method: "DELETE",
357
+ path: `${PREFIX}/:id/draft/edges`,
358
+ allow: MANAGE,
359
+ async handle(context) {
360
+ const from = text(context.url, "from")
361
+ const to = text(context.url, "to")
362
+ if (!from || !to) throw new AgentError("invalid", "from and to (role names) are required")
363
+ return draftEdit(context, expected(text(context.url, "expectedRevision")), (spec) => removeEdge(spec, from, to))
364
+ },
365
+ },
366
+ {
367
+ method: "POST",
368
+ path: `${PREFIX}/:id/publish`,
369
+ allow: MANAGE,
370
+ async handle(context) {
371
+ const body = await bodyOf(context.request)
372
+ only(body, ["expectedRevision"])
373
+ const { id } = await managed(context)
374
+ const result = await teams.publish(context.user.id, id, expected(body.expectedRevision))
375
+ await publishedEvent(context, result.team, result.published)
376
+ return ok(result)
377
+ },
378
+ },
379
+ statusRoute("pause"),
380
+ statusRoute("resume"),
381
+ statusRoute("archive"),
382
+ statusRoute("restore"),
383
+
384
+ // ---------- history ----------
385
+ {
386
+ method: "GET",
387
+ path: `${PREFIX}/:id/versions`,
388
+ allow: READ,
389
+ async handle(context) {
390
+ const team = await readable(context)
391
+ const limit = intParam(context.url, "limit")
392
+ const cursor = text(context.url, "cursor")
393
+ return ok(await teams.history(context.user.id, team.id, { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) }))
394
+ },
395
+ },
396
+ {
397
+ method: "GET",
398
+ path: `${PREFIX}/:id/versions/:version`,
399
+ allow: READ,
400
+ async handle(context) {
401
+ const team = await readable(context)
402
+ const raw = context.params.version!
403
+ const number = /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN
404
+ if (!Number.isSafeInteger(number) || number < 1) throw new AgentError("invalid", "version must be a version number")
405
+ const version = await teams.version(context.user.id, team.id, number)
406
+ if (!version) throw new AgentError("not_found", `Team ${team.id} has no retained version ${number}`)
407
+ return ok({ version })
408
+ },
409
+ },
410
+ {
411
+ method: "GET",
412
+ path: `${PREFIX}/:id/diff`,
413
+ allow: READ,
414
+ async handle(context) {
415
+ const team = await readable(context)
416
+ const versionOf = (name: string): number | undefined => {
417
+ const raw = text(context.url, name)
418
+ if (raw === undefined) return undefined
419
+ if (!/^[0-9]{1,9}$/.test(raw) || Number(raw) < 1) throw new AgentError("invalid", `${name} must be a version number${name === "to" ? " or draft" : ""}`)
420
+ return Number(raw)
421
+ }
422
+ const from = versionOf("from") ?? team.version
423
+ const to: number | "draft" = text(context.url, "to") === "draft" ? "draft" : (versionOf("to") ?? "draft")
424
+ return ok({ from, to, diff: await teams.diff(context.user.id, team.id, from, to) })
425
+ },
426
+ },
427
+
428
+ // ---------- staffing ----------
429
+ {
430
+ method: "GET",
431
+ path: `${PREFIX}/:id/staffing`,
432
+ allow: READ,
433
+ async handle(context) {
434
+ const team = await readable(context, true)
435
+ const ended = boolParam(context.url, "ended") === true
436
+ return ok({ items: await seats.list(context.user.id, team.id, ended ? {} : { current: true }) })
437
+ },
438
+ },
439
+ {
440
+ method: "PUT",
441
+ path: `${PREFIX}/:id/staffing`,
442
+ allow: STAFF,
443
+ async handle(context) {
444
+ appMayStaff(context)
445
+ const body = await bodyOf(context.request)
446
+ const key = idempotencyKeyOf(context.request)
447
+ const { id } = await managed(context)
448
+ const result = await staffing.attach(context.user.id, context.actor, id, body, key === undefined ? {} : { idempotencyKey: key })
449
+ if (result.created) await deps.audit?.audit({ actor: context.user.id, action: "team.staff", target: result.staffing.id, detail: context.actor.kind })
450
+ return storeAnswer(result.created, { staffing: result.staffing, ...(result.workspace ? { workspace: result.workspace } : {}) })
451
+ },
452
+ },
453
+ {
454
+ method: "POST",
455
+ path: `${PREFIX}/:id/staffing/launch`,
456
+ allow: STAFF,
457
+ async handle(context) {
458
+ appMayStaff(context)
459
+ const body = await bodyOf(context.request)
460
+ const key = idempotencyKeyOf(context.request)
461
+ const { id } = await managed(context)
462
+ const result = await staffing.launch(context.user.id, context.actor, id, body, key === undefined ? {} : { idempotencyKey: key })
463
+ if (result.created) await deps.audit?.audit({ actor: context.user.id, action: "team.staff.launch", target: result.request.id, detail: context.actor.kind })
464
+ return storeAnswer(result.created, { request: result.request, launch: result.launch })
465
+ },
466
+ },
467
+ {
468
+ method: "POST",
469
+ path: `${PREFIX}/:id/staffing/:staffing/replace`,
470
+ allow: STAFF,
471
+ async handle(context) {
472
+ appMayStaff(context)
473
+ const body = await bodyOf(context.request)
474
+ const { id } = await managed(context)
475
+ const result = await staffing.replace(context.user.id, context.actor, id, context.params.staffing!, body)
476
+ await deps.audit?.audit({ actor: context.user.id, action: "team.replace", target: result.ended.id, detail: context.actor.kind })
477
+ return ok(result)
478
+ },
479
+ },
480
+ {
481
+ method: "DELETE",
482
+ path: `${PREFIX}/:id/staffing/:staffing`,
483
+ allow: STAFF,
484
+ async handle(context) {
485
+ appMayStaff(context)
486
+ const revision = expected(text(context.url, "expectedRevision"))
487
+ const { id } = await managed(context)
488
+ const ended = await staffing.unstaff(context.user.id, context.actor, id, context.params.staffing!, revision)
489
+ await deps.audit?.audit({ actor: context.user.id, action: "team.unstaff", target: ended.id, detail: context.actor.kind })
490
+ return ok({ staffing: ended })
491
+ },
492
+ },
493
+
494
+ // ---------- roster and status ----------
495
+ {
496
+ method: "GET",
497
+ path: `${PREFIX}/:id/roster`,
498
+ allow: READ,
499
+ async handle(context) {
500
+ const team = await readable(context, true)
501
+ return ok({ roster: await staffing.roster(context.user.id, team.id) })
502
+ },
503
+ },
504
+ {
505
+ method: "GET",
506
+ path: `${PREFIX}/:id/status`,
507
+ allow: READ,
508
+ async handle(context) {
509
+ const team = await readable(context, true)
510
+ const roster = await staffing.roster(context.user.id, team.id)
511
+ const intake = await deps.intake?.summary(context.user.id, team.id)
512
+ return ok({ team, roster, ...(intake ? { intake } : {}) })
513
+ },
514
+ },
515
+ ]
516
+ }