@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,256 @@
1
+ // Team definition bodies (HWA-33): the frozen contract check (teamInputProblem, packages/protocol/src/teams.ts)
2
+ // plus the additive fields this store keeps next to it, normalization, the content digest and the revision diff.
3
+ //
4
+ // A TeamSpec is the publishable content of a team: everything a published revision freezes. The store adds the
5
+ // record fields around it (id, state, revision, version, paused, timestamps). Additive (contract questions in the
6
+ // HWA-33 handoff), all optional, none of them grants authority:
7
+ // - team `projects`: project names the team works on (filters).
8
+ // - role `responsibilities` (short lines), `profile` (desired profile ref {profileID, version?}), `prefs`
9
+ // (harness / model / machineID placement preferences for staffing), `minMembers` / `maxMembers`.
10
+ // Stricter than the contract (never looser): unknown fields are refused, a role description is bounded text, an
11
+ // edge's kinds are unique, and one ordered role pair is covered by at most one edge (a two-way edge covers both).
12
+ import { createHash } from "node:crypto"
13
+ import {
14
+ DESCRIPTION_MAX,
15
+ isContractID,
16
+ isLabel,
17
+ isSlug,
18
+ isText,
19
+ TEAM_LIMITS,
20
+ type TeamEdge,
21
+ type TeamMessageKind,
22
+ type TeamMode,
23
+ type TeamRole,
24
+ teamInputProblem,
25
+ } from "../../protocol"
26
+ import { canonicalBody } from "../agent/idempotency"
27
+ import { AgentError } from "../agent/errors"
28
+
29
+ export const TEAM_STORE_LIMITS = {
30
+ ...TEAM_LIMITS,
31
+ responsibilitiesPerRole: 16,
32
+ responsibilityMax: 200,
33
+ projectsPerTeam: 16,
34
+ /** Published revisions kept per team (older ones nobody pins are dropped). */
35
+ versionsRetained: 50,
36
+ /** Ended staffing records kept per team (oldest dropped first). */
37
+ endedSeatsRetained: 200,
38
+ } as const
39
+
40
+ /** Machine IDs as the hub mints them (same shape the workspace bindings accept). */
41
+ const MACHINE_ID = /^[A-Za-z0-9_.:-]{1,128}$/
42
+ const MODEL_MAX = 200
43
+
44
+ /** Staffing preferences of a role (advisory: staffing still runs under the caller's grants). */
45
+ export interface TeamRolePrefs {
46
+ readonly harness?: string
47
+ readonly model?: string
48
+ readonly machineID?: string
49
+ }
50
+
51
+ /** A contract TeamRole plus the additive fields. */
52
+ export interface TeamRoleSpec extends TeamRole {
53
+ readonly responsibilities?: readonly string[]
54
+ readonly profile?: { readonly profileID: string; readonly version?: number }
55
+ readonly prefs?: TeamRolePrefs
56
+ readonly minMembers?: number
57
+ readonly maxMembers?: number
58
+ }
59
+
60
+ /** The publishable content of a team. */
61
+ export interface TeamSpec {
62
+ readonly name: string
63
+ readonly title: string
64
+ readonly description?: string
65
+ readonly mode: TeamMode
66
+ readonly roles: readonly TeamRoleSpec[]
67
+ readonly entryRole: string
68
+ readonly edges: readonly TeamEdge[]
69
+ readonly tags?: readonly string[]
70
+ readonly metadata?: Readonly<Record<string, string>>
71
+ readonly projects?: readonly string[]
72
+ }
73
+
74
+ export interface TeamDiff {
75
+ /** Changed scalar fields, in a fixed order: title, description, mode, entryRole. */
76
+ readonly fields: readonly string[]
77
+ readonly roles: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] }
78
+ /** Edge keys `from>to` as drawn. */
79
+ readonly edges: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] }
80
+ readonly tags: { readonly added: readonly string[]; readonly removed: readonly string[] }
81
+ readonly metadata: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] }
82
+ readonly projects: { readonly added: readonly string[]; readonly removed: readonly string[] }
83
+ }
84
+
85
+ export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
86
+ const ROLE_FIELDS = new Set(["name", "label", "description", "externalContact", "responsibilities", "profile", "prefs", "minMembers", "maxMembers"])
87
+ const EDGE_FIELDS = new Set(["from", "to", "kinds", "direction"])
88
+ const PROFILE_FIELDS = new Set(["profileID", "version"])
89
+ const PREFS_FIELDS = new Set(["harness", "model", "machineID"])
90
+ const REQUIRED = new Set(["title", "mode", "roles", "entryRole"])
91
+ const SCALARS = ["title", "description", "mode", "entryRole"] as const
92
+
93
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
94
+ const fail = (message: string): never => {
95
+ throw new AgentError("invalid", message)
96
+ }
97
+ const printable = (key: string) => (/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)")
98
+
99
+ function onlyFields(v: Record<string, unknown>, allowed: ReadonlySet<string>, where: string): void {
100
+ for (const key of Object.keys(v)) if (!allowed.has(key)) fail(`${where}unknown field ${printable(key)}`)
101
+ }
102
+
103
+ function roleProblem(role: Record<string, unknown>): string | undefined {
104
+ const name = role.name as string
105
+ if (role.description !== undefined && !isText(role.description, DESCRIPTION_MAX)) return `role ${name} description must be at most ${DESCRIPTION_MAX} characters`
106
+ const r = role.responsibilities
107
+ if (r !== undefined) {
108
+ const limit = TEAM_STORE_LIMITS.responsibilitiesPerRole
109
+ if (!Array.isArray(r) || r.length > limit || !r.every((line) => isLabel(line, TEAM_STORE_LIMITS.responsibilityMax)))
110
+ return `role ${name} responsibilities must be at most ${limit} lines of 1–${TEAM_STORE_LIMITS.responsibilityMax} characters`
111
+ }
112
+ const p = role.profile
113
+ if (p !== undefined) {
114
+ if (!isObj(p)) return `role ${name} profile must be {profileID, version?}`
115
+ onlyFields(p, PROFILE_FIELDS, `role ${name} profile: `)
116
+ if (!isContractID("profile", p.profileID)) return `role ${name} profile.profileID must be a profile ID`
117
+ if (p.version !== undefined && !(Number.isSafeInteger(p.version) && (p.version as number) >= 1)) return `role ${name} profile.version must be a version number`
118
+ }
119
+ const prefs = role.prefs
120
+ if (prefs !== undefined) {
121
+ if (!isObj(prefs)) return `role ${name} prefs must be an object`
122
+ onlyFields(prefs, PREFS_FIELDS, `role ${name} prefs: `)
123
+ if (prefs.harness !== undefined && !isSlug(prefs.harness)) return `role ${name} prefs.harness must be a harness name`
124
+ if (prefs.model !== undefined && !isLabel(prefs.model, MODEL_MAX)) return `role ${name} prefs.model must be 1–${MODEL_MAX} characters`
125
+ if (prefs.machineID !== undefined && !(typeof prefs.machineID === "string" && MACHINE_ID.test(prefs.machineID))) return `role ${name} prefs.machineID must be a machine ID`
126
+ }
127
+ const cap = TEAM_LIMITS.sessionsPerRole
128
+ const { minMembers: min, maxMembers: max } = role
129
+ if (min !== undefined && !(Number.isSafeInteger(min) && (min as number) >= 0 && (min as number) <= cap)) return `role ${name} minMembers must be 0–${cap}`
130
+ if (max !== undefined && !(Number.isSafeInteger(max) && (max as number) >= 1 && (max as number) <= cap)) return `role ${name} maxMembers must be 1–${cap}`
131
+ if (min !== undefined && max !== undefined && (min as number) > (max as number)) return `role ${name} minMembers must not exceed maxMembers`
132
+ return undefined
133
+ }
134
+
135
+ function normalizedRole(r: Record<string, unknown>): TeamRoleSpec {
136
+ const profile = r.profile as Record<string, unknown> | undefined
137
+ const prefs = r.prefs as Record<string, unknown> | undefined
138
+ return {
139
+ name: r.name as string,
140
+ ...(r.label !== undefined ? { label: r.label as string } : {}),
141
+ ...(r.description !== undefined ? { description: r.description as string } : {}),
142
+ ...(r.externalContact !== undefined ? { externalContact: r.externalContact as boolean } : {}),
143
+ ...(r.responsibilities !== undefined ? { responsibilities: [...(r.responsibilities as string[])] } : {}),
144
+ ...(profile !== undefined ? { profile: { profileID: profile.profileID as string, ...(profile.version !== undefined ? { version: profile.version as number } : {}) } } : {}),
145
+ ...(prefs !== undefined
146
+ ? {
147
+ prefs: {
148
+ ...(prefs.harness !== undefined ? { harness: prefs.harness as string } : {}),
149
+ ...(prefs.model !== undefined ? { model: prefs.model as string } : {}),
150
+ ...(prefs.machineID !== undefined ? { machineID: prefs.machineID as string } : {}),
151
+ },
152
+ }
153
+ : {}),
154
+ ...(r.minMembers !== undefined ? { minMembers: r.minMembers as number } : {}),
155
+ ...(r.maxMembers !== undefined ? { maxMembers: r.maxMembers as number } : {}),
156
+ }
157
+ }
158
+
159
+ /** A validated, normalized team spec; throws AgentError invalid. */
160
+ export function teamSpecOf(input: unknown): TeamSpec {
161
+ if (!isObj(input)) return fail("body must be an object")
162
+ onlyFields(input, SPEC_FIELDS, "")
163
+ if (Array.isArray(input.roles)) for (const role of input.roles) if (isObj(role)) onlyFields(role, ROLE_FIELDS, `role ${printable(String(role.name))}: `)
164
+ if (Array.isArray(input.edges)) for (const edge of input.edges) if (isObj(edge)) onlyFields(edge, EDGE_FIELDS, "edge: ")
165
+ const problem = teamInputProblem(input)
166
+ if (problem) fail(problem)
167
+ const roles = input.roles as Record<string, unknown>[]
168
+ for (const role of roles) {
169
+ const p = roleProblem(role)
170
+ if (p) fail(p)
171
+ }
172
+ const projects = input.projects
173
+ if (projects !== undefined) {
174
+ const limit = TEAM_STORE_LIMITS.projectsPerTeam
175
+ if (!Array.isArray(projects) || projects.length > limit || !projects.every((p) => isLabel(p)) || new Set(projects).size !== projects.length)
176
+ fail(`projects must be at most ${limit} unique project names`)
177
+ }
178
+ const covered = new Set<string>()
179
+ const edges: TeamEdge[] = []
180
+ for (const e of (input.edges ?? []) as Record<string, unknown>[]) {
181
+ const kinds = e.kinds as TeamMessageKind[]
182
+ if (new Set(kinds).size !== kinds.length) fail(`edge ${e.from as string} → ${e.to as string} lists a kind twice`)
183
+ const pairs = e.direction === "two-way" ? [[e.from, e.to], [e.to, e.from]] : [[e.from, e.to]]
184
+ for (const [from, to] of pairs) {
185
+ const key = `${from as string} → ${to as string}`
186
+ if (covered.has(key)) fail(`There are two edges for ${key}; one edge per role pair (two-way covers both directions)`)
187
+ covered.add(key)
188
+ }
189
+ edges.push({ from: e.from as string, to: e.to as string, kinds: [...kinds].sort(), direction: e.direction as TeamEdge["direction"] })
190
+ }
191
+ return {
192
+ name: input.name as string,
193
+ title: input.title as string,
194
+ ...(input.description !== undefined ? { description: input.description as string } : {}),
195
+ mode: input.mode as TeamMode,
196
+ roles: roles.map(normalizedRole),
197
+ entryRole: input.entryRole as string,
198
+ edges,
199
+ ...(input.tags !== undefined ? { tags: [...(input.tags as string[])] } : {}),
200
+ ...(input.metadata !== undefined ? { metadata: { ...(input.metadata as Record<string, string>) } } : {}),
201
+ ...(projects !== undefined ? { projects: [...(projects as string[])] } : {}),
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The spec after a patch: fields replace the base's (roles and edges wholesale), null clears an optional one, the
207
+ * name never changes (it is the `#team` address), and the result is validated as a whole.
208
+ */
209
+ export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
210
+ if (!isObj(patch)) return fail("body must be an object")
211
+ onlyFields(patch, SPEC_FIELDS, "")
212
+ const keys = Object.keys(patch)
213
+ if (keys.length === 0) fail("Nothing to change")
214
+ if (patch.name !== undefined && patch.name !== base.name) fail("A team's name cannot change (it is the #team address); create a new team")
215
+ const next: Record<string, unknown> = { ...base }
216
+ for (const key of keys) {
217
+ const value = patch[key]
218
+ if (value === null) {
219
+ if (REQUIRED.has(key) || key === "name") fail(`${key} cannot be cleared`)
220
+ if (key === "edges") next.edges = []
221
+ else delete next[key]
222
+ } else if (value !== undefined) next[key] = value
223
+ }
224
+ return teamSpecOf(next)
225
+ }
226
+
227
+ /** sha256 of the spec's canonical JSON (key order never matters). */
228
+ export const specDigest = (spec: TeamSpec): string => createHash("sha256").update(canonicalBody(spec)).digest("hex")
229
+
230
+ const same = (a: unknown, b: unknown) => canonicalBody(a) === canonicalBody(b)
231
+
232
+ function setDiff(from: readonly string[] = [], to: readonly string[] = []) {
233
+ return { added: to.filter((x) => !from.includes(x)), removed: from.filter((x) => !to.includes(x)) }
234
+ }
235
+
236
+ function keyedDiff<T>(from: ReadonlyMap<string, T>, to: ReadonlyMap<string, T>) {
237
+ return {
238
+ added: [...to.keys()].filter((k) => !from.has(k)),
239
+ removed: [...from.keys()].filter((k) => !to.has(k)),
240
+ changed: [...to.keys()].filter((k) => from.has(k) && !same(from.get(k), to.get(k))),
241
+ }
242
+ }
243
+
244
+ /** What changed from one spec to another (names and keys only, never descriptions). */
245
+ export function diffTeamSpecs(from: TeamSpec, to: TeamSpec): TeamDiff {
246
+ const edgeMap = (s: TeamSpec) => new Map(s.edges.map((e) => [`${e.from}>${e.to}`, e] as const))
247
+ const metaMap = (s: TeamSpec) => new Map(Object.entries(s.metadata ?? {}))
248
+ return {
249
+ fields: SCALARS.filter((f) => !same(from[f], to[f])),
250
+ roles: keyedDiff(new Map(from.roles.map((r) => [r.name, r] as const)), new Map(to.roles.map((r) => [r.name, r] as const))),
251
+ edges: keyedDiff(edgeMap(from), edgeMap(to)),
252
+ tags: setDiff(from.tags, to.tags),
253
+ metadata: keyedDiff(metaMap(from), metaMap(to)),
254
+ projects: setDiff(from.projects, to.projects),
255
+ }
256
+ }
@@ -0,0 +1,408 @@
1
+ // The master situation board (HWA-10; contract: Workspaces `GET /workspaces/:id/board` in docs/plans/2026-10-30-
2
+ // hub-workspaces-artifacts-contracts.md and its HWA-06 clarification):
3
+ //
4
+ // GET /api/agent/workspaces/:id/board workspaces:read + sessions:read
5
+ //
6
+ // One read-only, bounded snapshot of a workspace for whoever coordinates it: the workspace, its current members
7
+ // with presence, last-known session state and blocker references, supervision links, assignments with their
8
+ // explicit status, open Decisions concerning members, and artifact references.
9
+ //
10
+ // Who: users (their own workspaces; another user's is not_found). App tokens with workspaces:read and
11
+ // sessions:read (the board names sessions and shows presence). Session tokens are refused: membership grants
12
+ // nothing, and a master session reading its board through its own token would be a new authority (an open
13
+ // contract question, not implemented). Sections an app may not read are `{status: "omitted", reason: "scope"}`:
14
+ // Decisions need messages:read and show only those the app asked (as GET /decisions does); artifacts need
15
+ // artifacts:read and are listed through the artifact store's ACL as the user.
16
+ //
17
+ // What it never carries: prompts, message bodies, Decision questions/options/answers, blocker summaries,
18
+ // assignment criteria or artifact titles. Those stay behind their existing detail routes (GET /sessions/:addr,
19
+ // /decisions/:id, /workspaces/:id/assignments/:id, /artifacts/:id), each with its own authorization. Assignment
20
+ // titles are shown: the same caller reads them on the assignment routes with the same scopes.
21
+ //
22
+ // Truthfulness: assignment status is exactly the stored, explicitly set status; idleness, presence or departure
23
+ // never complete, start or cancel anything. A session the catalog does not report here (another pod, offline) has
24
+ // `blockers: {reported: false}` rather than an empty list. Members and open assignments carry `stale` markers when
25
+ // the hub cannot vouch for them now (offline, archived, deleted or unknown sessions; departed or missing assignees;
26
+ // no update within staleAfterMs). Unwired sections are `{status: "unsupported", feature}`; a section whose source
27
+ // fails is `{status: "failed"}` without failing the whole board.
28
+ //
29
+ // Bounds and fanout: every section is capped (BOARD_LIMITS) and says `truncated`; the session facts are one
30
+ // batched lookup over the shown members from the hub's own directory and catalog — the board never calls a spoke.
31
+ // No model or runtime runs here, nothing is written and no event is appended (the compact journal is unchanged).
32
+ import { LIST_LIMIT_MAX, WORKSPACE_LIMITS, WORKSPACE_SCOPES, type Artifact, type Page } from "../../protocol"
33
+ import type { AgentActor } from "../agent/actor"
34
+ import type { Decision, Decisions } from "../agent/decisions"
35
+ import type { Directory } from "../agent/directory"
36
+ import { partyFromActor } from "../agent/envelope"
37
+ import { AgentError } from "../agent/errors"
38
+ import type { ReadRegistry } from "../agent/read-routes"
39
+ import type { AgentRoute } from "../agent/routes"
40
+ import type { ArtifactPrincipal } from "../artifacts/access"
41
+ import type { AssignmentStatus, MembershipStore, SessionPresence, StoredAssignment } from "./members"
42
+ import type { WorkspaceStore } from "./store"
43
+ import type { SupervisionStore } from "./supervision"
44
+
45
+ const PREFIX = "/api/agent/workspaces"
46
+ const NO_STORE = { "cache-control": "no-store" }
47
+ const [READ_SCOPE] = WORKSPACE_SCOPES
48
+
49
+ export interface BoardLimits {
50
+ /** Current members shown (the session lookup covers only these). */
51
+ readonly members: number
52
+ /** Open (not done or cancelled) assignments shown, oldest first. */
53
+ readonly assignments: number
54
+ /** Most recently closed assignments shown. */
55
+ readonly closedAssignments: number
56
+ /** Supervision links shown. */
57
+ readonly links: number
58
+ /** Open Decisions shown, newest first. */
59
+ readonly decisions: number
60
+ /** Artifact references shown, most recently updated first. */
61
+ readonly artifacts: number
62
+ /** Blocker references per member. */
63
+ readonly blockersPerMember: number
64
+ /** An open assignment without an update for this long is marked `no_recent_update`. */
65
+ readonly assignmentStaleMs: number
66
+ }
67
+
68
+ export const BOARD_LIMITS: BoardLimits = {
69
+ members: WORKSPACE_LIMITS.membersPerWorkspace,
70
+ assignments: LIST_LIMIT_MAX,
71
+ closedAssignments: 20,
72
+ links: WORKSPACE_LIMITS.supervisionLinksPerWorkspace,
73
+ decisions: 50,
74
+ artifacts: 50,
75
+ blockersPerMember: 8,
76
+ assignmentStaleMs: 24 * 60 * 60_000,
77
+ }
78
+
79
+ /** A blocker reference from the catalog (`summary` may be present at the source; the board never copies it). */
80
+ export interface BoardBlockerSource {
81
+ readonly kind: string
82
+ readonly id: string
83
+ readonly sessionID?: string
84
+ readonly summary?: string
85
+ }
86
+
87
+ /** What the hub knows about a member's session now, without asking a spoke. */
88
+ export interface BoardSession {
89
+ /** idle / working / blocked / error from the catalog; "offline" when no spoke reports it; else last known. */
90
+ readonly state: string
91
+ readonly online: boolean
92
+ readonly lastSeen?: number
93
+ readonly machineID?: string
94
+ readonly name?: string
95
+ readonly busySince?: number
96
+ /** Present only when this hub's catalog reports the session now (absent: not reported, never "none"). */
97
+ readonly blockers?: readonly BoardBlockerSource[]
98
+ }
99
+
100
+ /** One batched lookup of the user's sessions; IDs it does not know are left out of the map. */
101
+ export type BoardSessions = (userID: string, sessionIDs: readonly string[]) => Promise<ReadonlyMap<string, BoardSession>>
102
+
103
+ export interface BoardDeps {
104
+ readonly workspaces: Pick<WorkspaceStore, "require">
105
+ readonly members: Pick<MembershipStore, "members" | "assignments">
106
+ readonly supervision: Pick<SupervisionStore, "graph">
107
+ /** Session facts (the hub's directory and catalog); without it members show presence only. */
108
+ readonly sessions?: BoardSessions
109
+ /** Decisions (ask @me); without it the section is unsupported. */
110
+ readonly decisions?: Pick<Decisions, "list">
111
+ /** The artifact store (its ACL applies); without it the section is unsupported. */
112
+ readonly artifacts?: { list(userID: string, principal: ArtifactPrincipal, options?: { limit?: number; workspaceID?: string }): Promise<Page<Artifact>> }
113
+ readonly limits?: Partial<BoardLimits>
114
+ readonly now?: () => number
115
+ }
116
+
117
+ type Section<T> =
118
+ | { readonly status: "ok"; readonly items: readonly T[]; readonly truncated: boolean }
119
+ | { readonly status: "omitted"; readonly reason: "scope"; readonly scope: string }
120
+ | { readonly status: "unsupported"; readonly feature: string }
121
+ | { readonly status: "failed" }
122
+
123
+ export type AssignmentStale = "assignee_left" | "assignee_missing" | "assignee_offline" | "no_recent_update"
124
+
125
+ export interface BoardMember {
126
+ readonly memberID: string
127
+ readonly sessionID: string
128
+ readonly role: string
129
+ readonly generation: number
130
+ readonly revision: number
131
+ readonly joinedAt: number
132
+ readonly master: boolean
133
+ readonly presence: SessionPresence
134
+ readonly session?: Omit<BoardSession, "blockers">
135
+ readonly blockers: { readonly reported: false } | { readonly reported: true; readonly count: number; readonly items: readonly { kind: string; id: string; sessionID?: string }[]; readonly truncated?: true }
136
+ /** Not-closed assignments by status. */
137
+ readonly assignments: { readonly open: number; readonly in_progress: number; readonly blocked: number }
138
+ /** Open Decisions concerning this member's session (absent when the Decisions section is not readable). */
139
+ readonly decisions?: number
140
+ /** Why the hub cannot vouch for this member's session now (absent: online). */
141
+ readonly stale?: Exclude<SessionPresence, "online">
142
+ }
143
+
144
+ export interface BoardAssignment {
145
+ readonly id: string
146
+ readonly memberID: string
147
+ readonly title: string
148
+ readonly status: AssignmentStatus
149
+ readonly ref?: string
150
+ readonly revision: number
151
+ readonly createdAt: number
152
+ readonly updatedAt: number
153
+ readonly closedAt?: number
154
+ readonly assignee: "current" | "left" | "missing"
155
+ readonly stale?: readonly AssignmentStale[]
156
+ }
157
+
158
+ export interface BoardDecision {
159
+ readonly id: string
160
+ readonly state: Decision["state"]
161
+ readonly urgency: Decision["urgency"]
162
+ readonly tier: Decision["tier"]
163
+ readonly at: number
164
+ readonly expiresAt: number
165
+ readonly deadline?: number
166
+ readonly escalatedAt?: number
167
+ /** Member sessions it concerns (the asker when a member, then `about`). */
168
+ readonly sessionIDs: readonly string[]
169
+ readonly memberIDs: readonly string[]
170
+ readonly stale?: "past_deadline"
171
+ }
172
+
173
+ export interface BoardArtifact {
174
+ readonly id: string
175
+ readonly kind: Artifact["kind"]
176
+ readonly visibility: Artifact["visibility"]
177
+ readonly state: Artifact["state"]
178
+ readonly revision: number
179
+ readonly latestRevisionID: string
180
+ readonly updatedAt: number
181
+ }
182
+
183
+ const CLOSED: ReadonlySet<AssignmentStatus> = new Set(["done", "cancelled"])
184
+ const ASSIGNMENT_STATUS_LIST: readonly AssignmentStatus[] = ["open", "in_progress", "blocked", "done", "cancelled"]
185
+
186
+ const hasScope = (actor: AgentActor, scope: string) => actor.kind !== "app" || actor.scopes.includes(scope)
187
+
188
+ /** A section's source failed: say so instead of failing the board (unsupported stays truthful). */
189
+ function failure<T>(feature: string, cause: unknown): Section<T> {
190
+ if (cause instanceof AgentError && cause.code === "unsupported") return { status: "unsupported", feature }
191
+ console.error(`[hub] workspace board: ${feature} failed: ${(cause as Error).message}`)
192
+ return { status: "failed" }
193
+ }
194
+
195
+ /** The production session facts: the user's directory and this hub's catalog, never a spoke. */
196
+ export function registryBoardSessions(directory: Pick<Directory, "resolve">, registry: Pick<ReadRegistry, "session" | "sessionTenant">): BoardSessions {
197
+ return async (userID, sessionIDs) => {
198
+ const out = new Map<string, BoardSession>()
199
+ for (const sessionID of sessionIDs) {
200
+ let entry
201
+ try {
202
+ entry = directory.resolve(userID, sessionID).sessions.find((s) => s.sessionID === sessionID)
203
+ } catch (cause) {
204
+ if (cause instanceof AgentError) continue
205
+ throw cause
206
+ }
207
+ if (!entry) continue
208
+ const named = { ...(entry.name ? { name: `@${entry.name}` } : {}), ...(entry.machineID ? { machineID: entry.machineID } : {}) }
209
+ const live = registry.sessionTenant(sessionID) === userID ? registry.session(sessionID) : undefined
210
+ if (live) {
211
+ const since = live.status.find((s) => s.id === sessionID) as { since?: number } | undefined
212
+ out.set(sessionID, {
213
+ state: live.state,
214
+ online: true,
215
+ ...named,
216
+ ...(since?.since !== undefined ? { busySince: since.since } : {}),
217
+ blockers: live.blockers.map((b) => ({ kind: b.kind, id: b.id, ...(b.sessionID && b.sessionID !== sessionID ? { sessionID: b.sessionID } : {}) })),
218
+ })
219
+ } else if (!entry.online) {
220
+ out.set(sessionID, { state: "offline", online: false, ...named, ...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}) })
221
+ } else {
222
+ // Online per the (shared) directory but not in this hub's catalog: state as last known, blockers unknown.
223
+ out.set(sessionID, { state: entry.state ?? "unknown", online: true, ...named })
224
+ }
225
+ }
226
+ return out
227
+ }
228
+ }
229
+
230
+ /** Every retained assignment (≤ open + closed caps, so at most a couple of pages). */
231
+ async function allAssignments(members: BoardDeps["members"], userID: string, workspaceID: string) {
232
+ const out = []
233
+ let cursor: string | undefined
234
+ do {
235
+ const page = await members.assignments(userID, workspaceID, { limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
236
+ out.push(...page.items)
237
+ cursor = page.cursor
238
+ } while (cursor)
239
+ return out
240
+ }
241
+
242
+ /** Builds the board for one caller. Read-only. */
243
+ export async function workspaceBoard(deps: BoardDeps, userID: string, actor: AgentActor, workspaceID: string) {
244
+ const limits = { ...BOARD_LIMITS, ...deps.limits }
245
+ const now = (deps.now ?? Date.now)()
246
+ const workspace = await deps.workspaces.require(userID, workspaceID)
247
+ const [memberViews, assignmentViews, graph] = await Promise.all([
248
+ deps.members.members(userID, workspaceID),
249
+ allAssignments(deps.members, userID, workspaceID),
250
+ deps.supervision.graph(userID, workspaceID),
251
+ ])
252
+
253
+ // ---------- Decisions (open, concerning current members) ----------
254
+ const sessionToMember = new Map(memberViews.map((v) => [v.member.sessionID, v.member.id]))
255
+ let decisions: Section<BoardDecision>
256
+ const perSession = new Map<string, number>()
257
+ if (!deps.decisions) decisions = { status: "unsupported", feature: "decisions" }
258
+ else if (!hasScope(actor, "messages:read")) decisions = { status: "omitted", reason: "scope", scope: "messages:read" }
259
+ else {
260
+ try {
261
+ const open = await deps.decisions.list(userID, { state: "open", ...(actor.kind === "user" ? {} : { from: partyFromActor(actor) }) })
262
+ const relevant: BoardDecision[] = []
263
+ for (const d of open) {
264
+ const sessionIDs = [...new Set([...(d.from.kind === "session" ? [d.from.sessionID] : []), ...d.about])].filter((s) => sessionToMember.has(s))
265
+ if (sessionIDs.length === 0) continue
266
+ for (const s of sessionIDs) perSession.set(s, (perSession.get(s) ?? 0) + 1)
267
+ relevant.push({
268
+ id: d.id,
269
+ state: d.state,
270
+ urgency: d.urgency,
271
+ tier: d.tier,
272
+ at: d.at,
273
+ expiresAt: d.expiresAt,
274
+ ...(d.deadline !== undefined ? { deadline: d.deadline } : {}),
275
+ ...(d.escalatedAt !== undefined ? { escalatedAt: d.escalatedAt } : {}),
276
+ sessionIDs,
277
+ memberIDs: sessionIDs.map((s) => sessionToMember.get(s)!),
278
+ ...(d.deadline !== undefined && d.deadline < now ? { stale: "past_deadline" as const } : {}),
279
+ })
280
+ }
281
+ decisions = { status: "ok", items: relevant.slice(0, limits.decisions), truncated: relevant.length > limits.decisions }
282
+ } catch (cause) {
283
+ decisions = failure("decisions", cause)
284
+ }
285
+ }
286
+
287
+ // ---------- members ----------
288
+ const shown = memberViews.slice(0, limits.members)
289
+ const facts = deps.sessions && shown.length > 0 ? await deps.sessions(userID, shown.map((v) => v.member.sessionID)) : new Map<string, BoardSession>()
290
+ const workload = new Map<string, { open: number; in_progress: number; blocked: number }>()
291
+ for (const { assignment } of assignmentViews) {
292
+ if (CLOSED.has(assignment.status)) continue
293
+ const counts = workload.get(assignment.memberID) ?? { open: 0, in_progress: 0, blocked: 0 }
294
+ counts[assignment.status as "open" | "in_progress" | "blocked"]++
295
+ workload.set(assignment.memberID, counts)
296
+ }
297
+ const boardMembers = shown.map(({ member, presence }): BoardMember => {
298
+ const fact = facts.get(member.sessionID)
299
+ const { blockers, ...session } = fact ?? ({} as BoardSession)
300
+ const refs = blockers?.map((b) => ({ kind: b.kind, id: b.id, ...(b.sessionID ? { sessionID: b.sessionID } : {}) }))
301
+ return {
302
+ memberID: member.id,
303
+ sessionID: member.sessionID,
304
+ role: member.role,
305
+ generation: member.generation,
306
+ revision: member.revision,
307
+ joinedAt: member.joinedAt,
308
+ master: workspace.masterMemberID === member.id,
309
+ presence,
310
+ ...(fact ? { session } : {}),
311
+ blockers: refs
312
+ ? { reported: true, count: refs.length, items: refs.slice(0, limits.blockersPerMember), ...(refs.length > limits.blockersPerMember ? { truncated: true as const } : {}) }
313
+ : { reported: false },
314
+ assignments: workload.get(member.id) ?? { open: 0, in_progress: 0, blocked: 0 },
315
+ ...(decisions.status === "ok" ? { decisions: perSession.get(member.sessionID) ?? 0 } : {}),
316
+ ...(presence !== "online" ? { stale: presence } : {}),
317
+ }
318
+ })
319
+
320
+ // ---------- assignments ----------
321
+ const counts = Object.fromEntries(ASSIGNMENT_STATUS_LIST.map((s) => [s, 0])) as Record<AssignmentStatus, number>
322
+ for (const { assignment } of assignmentViews) counts[assignment.status]++
323
+ const open = assignmentViews.filter((v) => !CLOSED.has(v.assignment.status))
324
+ const closed = assignmentViews
325
+ .filter((v) => CLOSED.has(v.assignment.status))
326
+ .sort((a, b) => ((b.assignment as StoredAssignment).closedAt ?? 0) - ((a.assignment as StoredAssignment).closedAt ?? 0))
327
+ const assignmentItems = [...open.slice(0, limits.assignments), ...closed.slice(0, limits.closedAssignments)].map(({ assignment, member, presence }): BoardAssignment => {
328
+ const a = assignment as StoredAssignment
329
+ const stale: AssignmentStale[] = []
330
+ if (!CLOSED.has(a.status)) {
331
+ if (member === "left") stale.push("assignee_left")
332
+ else if (member === "missing") stale.push("assignee_missing")
333
+ else if (presence !== "online") stale.push("assignee_offline")
334
+ if (now - a.updatedAt > limits.assignmentStaleMs) stale.push("no_recent_update")
335
+ }
336
+ return {
337
+ id: a.id,
338
+ memberID: a.memberID,
339
+ title: a.title,
340
+ status: a.status,
341
+ ...(a.ref !== undefined ? { ref: a.ref } : {}),
342
+ revision: a.revision,
343
+ createdAt: a.createdAt,
344
+ updatedAt: a.updatedAt,
345
+ ...(a.closedAt !== undefined ? { closedAt: a.closedAt } : {}),
346
+ assignee: member,
347
+ ...(stale.length ? { stale } : {}),
348
+ }
349
+ })
350
+
351
+ // ---------- artifacts (references only, through the artifact ACL as the user) ----------
352
+ let artifacts: Section<BoardArtifact>
353
+ if (!deps.artifacts) artifacts = { status: "unsupported", feature: "artifacts" }
354
+ else if (!hasScope(actor, "artifacts:read")) artifacts = { status: "omitted", reason: "scope", scope: "artifacts:read" }
355
+ else {
356
+ try {
357
+ const page = await deps.artifacts.list(userID, { kind: "user" }, { workspaceID, limit: limits.artifacts })
358
+ artifacts = {
359
+ status: "ok",
360
+ items: page.items.slice(0, limits.artifacts).map((a) => ({ id: a.id, kind: a.kind, visibility: a.visibility, state: a.state, revision: a.revision, latestRevisionID: a.latestRevisionID, updatedAt: a.updatedAt })),
361
+ truncated: page.cursor !== undefined || page.items.length > limits.artifacts,
362
+ }
363
+ } catch (cause) {
364
+ artifacts = failure("artifacts", cause)
365
+ }
366
+ }
367
+
368
+ return {
369
+ v: 1 as const,
370
+ at: now,
371
+ staleAfterMs: limits.assignmentStaleMs,
372
+ workspace: {
373
+ id: workspace.id,
374
+ title: workspace.title,
375
+ state: workspace.state,
376
+ revision: workspace.revision,
377
+ ...(workspace.masterMemberID ? { masterMemberID: workspace.masterMemberID } : {}),
378
+ createdAt: workspace.createdAt,
379
+ updatedAt: workspace.updatedAt,
380
+ },
381
+ members: { items: boardMembers, total: memberViews.length, truncated: memberViews.length > shown.length },
382
+ supervision: {
383
+ revision: graph.revision,
384
+ links: graph.links.slice(0, limits.links).map(({ link, member, supervisor }) => ({ memberID: link.memberID, supervisorMemberID: link.supervisorMemberID, order: link.order, member, supervisor })),
385
+ truncated: graph.links.length > limits.links,
386
+ },
387
+ assignments: { items: assignmentItems, counts, truncated: open.length > limits.assignments || closed.length > limits.closedAssignments },
388
+ decisions,
389
+ artifacts,
390
+ }
391
+ }
392
+
393
+ export type WorkspaceBoard = Awaited<ReturnType<typeof workspaceBoard>>
394
+
395
+ /** GET /workspaces/:id/board. */
396
+ export function boardRoutes(deps: BoardDeps): AgentRoute[] {
397
+ return [
398
+ {
399
+ method: "GET",
400
+ path: `${PREFIX}/:id/board`,
401
+ allow: { scope: READ_SCOPE },
402
+ async handle({ actor, user, params }) {
403
+ if (!hasScope(actor, "sessions:read")) throw new AgentError("forbidden", "The board also needs scope sessions:read")
404
+ return Response.json(await workspaceBoard(deps, user.id, actor, params.id!), { headers: NO_STORE })
405
+ },
406
+ },
407
+ ]
408
+ }