@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,571 @@
1
+ // Logical coordination workspaces and their host repository bindings (HWA-03; contract
2
+ // docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, packages/protocol/src/hub-workspace.ts).
3
+ //
4
+ // Storage: StateStore collections under the hub user (the tenant key), so the standalone file store and the
5
+ // cluster's Postgres hub_state table behave alike and no schema migration is needed:
6
+ // - `workspaces`: one HubWorkspace per key (its ID). `revision` is the StateStore version of the key: every write
7
+ // is version-checked (ifVersion), so a stale `expectedRevision` is a 409 conflict with `currentRevision`, and of
8
+ // two writers holding one revision exactly one wins. Keys are never deleted, so revisions are never reused.
9
+ // - `workspace-bindings`: one aggregate per workspace (key: workspace ID) holding its HostRepoBindings. Identity is
10
+ // the explicit machineID + canonical absolute repo; IDs are minted, never derived from paths.
11
+ // - `workspace-index` (key `active`): the reservations counting active workspaces toward the per-user cap. A
12
+ // create or restore reserves a slot by CAS before writing the record; archive gives it back. The index is
13
+ // derived state: when it is missing or full it is rebuilt from the records (active records count, reservations
14
+ // younger than RESERVATION_MS whose record is not active yet count, anything else is dropped), so a creator that
15
+ // crashed between reservation and write frees its slot after RESERVATION_MS.
16
+ // - `workspace-idempotency`: Idempotency-Key → {workspaceID, digest, at}, created with ifVersion 0 (the claim),
17
+ // valid IDEMPOTENCY_TTL_MS and pruned when expired.
18
+ //
19
+ // Records from other builds: a v1 record missing optional fields reads with defaults (state active, updatedAt =
20
+ // createdAt, revision = stored version). A record with a newer `v` is never guessed at: lists skip it and reads
21
+ // and writes answer 422 unsupported, leaving it untouched. A malformed record is skipped by lists and a 500 on read.
22
+ // Rollback: hubs before HWA-03 never read these collections (separate files / hub_state rows), so rolling back
23
+ // leaves them dormant and rolling forward picks them up again.
24
+ //
25
+ // Durability: writes go through the StateStore; the standalone file store debounces (FILE_STATE_DEBOUNCE_MS) and
26
+ // flushes on stop. Callers that acknowledge durability (HWA-11 checkpoints) call `flush()` before answering.
27
+ // Membership, masters and supervision grant nothing: this store holds records, never authority. Native session
28
+ // ancestry (parentID/rootID), UserRecord.agent and the console's inferred groups are not touched.
29
+ import { createHash, randomBytes } from "node:crypto"
30
+ import {
31
+ CONTRACT_VERSION,
32
+ DESCRIPTION_MAX,
33
+ type HostRepoBinding,
34
+ type HubWorkspace,
35
+ ID_PREFIXES,
36
+ IDEMPOTENCY_KEY,
37
+ IDEMPOTENCY_TTL_MS,
38
+ type IdKind,
39
+ isAbsolutePath,
40
+ isContractID,
41
+ isExpectedRevision,
42
+ isLabel,
43
+ isText,
44
+ LIST_LIMIT_DEFAULT,
45
+ LIST_LIMIT_MAX,
46
+ metadataProblem,
47
+ type Page,
48
+ RECORD_STATES,
49
+ type RecordState,
50
+ tagsProblem,
51
+ WORKSPACE_LIMITS,
52
+ workspaceInputProblem,
53
+ } from "../../protocol"
54
+ import { AgentError } from "../agent/errors"
55
+ import { updateVersioned } from "../agent/versioned"
56
+ import type { Clock, StateStore } from "../backend/types"
57
+
58
+ export const WORKSPACES = "workspaces"
59
+ export const WORKSPACE_BINDINGS = "workspace-bindings"
60
+ export const WORKSPACE_INDEX = "workspace-index"
61
+ export const WORKSPACE_IDEMPOTENCY = "workspace-idempotency"
62
+ /** The workspace-index key. */
63
+ const ACTIVE = "active"
64
+ /** How long a reservation (index slot, idempotency key) without its record blocks, before it counts as crashed. */
65
+ export const RESERVATION_MS = 60_000
66
+ /** Machine IDs as the hub mints them (`mch_…`), bounded. */
67
+ export const MACHINE_ID = /^[A-Za-z0-9_.:-]{1,128}$/
68
+ /** Version-checked write attempts before a contended change gives up. */
69
+ const ATTEMPTS = 16
70
+ /** Reads of an idempotency key's workspace: its creator may be between the claim and the write. */
71
+ const READS = 5
72
+
73
+ export type WorkspaceStoreLimits = { readonly [K in "workspacesPerUser" | "bindingsPerWorkspace"]: number }
74
+
75
+ /** Says whether the user owns the (unrevoked) machine; routes wire it to the hub's machine records. */
76
+ export type MachineOwnership = (userID: string, machineID: string) => boolean | Promise<boolean>
77
+
78
+ export interface WorkspaceStoreDeps {
79
+ readonly state: StateStore
80
+ /** Required: a binding names a machine of the binding user only. */
81
+ readonly ownsMachine: MachineOwnership
82
+ readonly now?: Clock
83
+ readonly newID?: (kind: IdKind) => string
84
+ /** Lower caps for tests; defaults are WORKSPACE_LIMITS. */
85
+ readonly limits?: Partial<WorkspaceStoreLimits>
86
+ }
87
+
88
+ export interface WorkspaceListQuery {
89
+ /** Only this state (default: both). */
90
+ readonly state?: string
91
+ readonly limit?: number
92
+ readonly cursor?: string
93
+ }
94
+
95
+ /** The fields PATCH /workspaces/:id may change; null clears an optional one. */
96
+ export interface WorkspacePatch {
97
+ readonly title?: string
98
+ readonly description?: string | null
99
+ readonly tags?: readonly string[] | null
100
+ readonly metadata?: Readonly<Record<string, string>> | null
101
+ }
102
+
103
+ export interface MachineBinding {
104
+ readonly workspaceID: string
105
+ readonly binding: HostRepoBinding
106
+ }
107
+
108
+ interface IndexEntry {
109
+ readonly id: string
110
+ readonly at: number
111
+ }
112
+ interface IndexDoc {
113
+ readonly v: 1
114
+ readonly entries: readonly IndexEntry[]
115
+ }
116
+ interface IdempotencyDoc {
117
+ readonly v: 1
118
+ readonly workspaceID: string
119
+ readonly digest: string
120
+ readonly at: number
121
+ }
122
+ interface BindingsDoc {
123
+ readonly v: 1
124
+ readonly workspaceID: string
125
+ readonly bindings: readonly HostRepoBinding[]
126
+ }
127
+
128
+ type Readout = { readonly ok: true; readonly workspace: HubWorkspace } | { readonly ok: false; readonly why: "newer" | "malformed"; readonly v?: unknown }
129
+
130
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
131
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
132
+ const PATCH_KEYS = new Set(["title", "description", "tags", "metadata"])
133
+
134
+ const conflict = (what: string, currentRevision: number) =>
135
+ new AgentError("conflict", `${what} changed since you read it (revision ${currentRevision} now)`, {
136
+ hint: "Read it again and retry with its current revision",
137
+ extra: { currentRevision },
138
+ })
139
+
140
+ /** A stored workspace as this build reads it (defaults for older v1 records; newer versions are refused). */
141
+ export function readWorkspace(key: string, raw: unknown, version: number): Readout {
142
+ if (!isObj(raw)) return { ok: false, why: "malformed" }
143
+ const v = raw.v ?? CONTRACT_VERSION
144
+ if (isNum(v) && v > CONTRACT_VERSION) return { ok: false, why: "newer", v }
145
+ if (v !== CONTRACT_VERSION) return { ok: false, why: "malformed" }
146
+ const { id, title, description, tags, metadata, masterMemberID } = raw
147
+ const state = raw.state ?? "active"
148
+ const createdAt = raw.createdAt ?? 0
149
+ const updatedAt = raw.updatedAt ?? createdAt
150
+ const valid =
151
+ id === key &&
152
+ typeof title === "string" &&
153
+ (description === undefined || typeof description === "string") &&
154
+ (tags === undefined || (Array.isArray(tags) && tags.every((t) => typeof t === "string"))) &&
155
+ (metadata === undefined || (isObj(metadata) && Object.values(metadata).every((m) => typeof m === "string"))) &&
156
+ (masterMemberID === undefined || typeof masterMemberID === "string") &&
157
+ RECORD_STATES.includes(state as RecordState) &&
158
+ isNum(createdAt) &&
159
+ isNum(updatedAt)
160
+ if (!valid) return { ok: false, why: "malformed" }
161
+ return {
162
+ ok: true,
163
+ workspace: {
164
+ v: 1,
165
+ id: id as string,
166
+ title: title as string,
167
+ ...(description !== undefined ? { description: description as string } : {}),
168
+ state: state as RecordState,
169
+ ...(tags !== undefined ? { tags: tags as string[] } : {}),
170
+ ...(metadata !== undefined ? { metadata: metadata as Record<string, string> } : {}),
171
+ ...(masterMemberID !== undefined ? { masterMemberID: masterMemberID as string } : {}),
172
+ revision: version,
173
+ createdAt: createdAt as number,
174
+ updatedAt: updatedAt as number,
175
+ },
176
+ }
177
+ }
178
+
179
+ /** The create body, validated by the contract and reduced to the fields a client may set. */
180
+ function createBody(input: unknown): Pick<HubWorkspace, "title" | "description" | "tags" | "metadata"> {
181
+ const problem = workspaceInputProblem(input)
182
+ if (problem) throw new AgentError("invalid", problem)
183
+ const body = input as Record<string, unknown>
184
+ return {
185
+ title: body.title as string,
186
+ ...(body.description !== undefined ? { description: body.description as string } : {}),
187
+ ...(body.tags !== undefined ? { tags: [...(body.tags as string[])] } : {}),
188
+ ...(body.metadata !== undefined ? { metadata: { ...(body.metadata as Record<string, string>) } } : {}),
189
+ }
190
+ }
191
+
192
+ function patchBody(input: unknown): WorkspacePatch {
193
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
194
+ const keys = Object.keys(input)
195
+ const unknown = keys.find((k) => !PATCH_KEYS.has(k))
196
+ if (unknown !== undefined)
197
+ throw new AgentError("invalid", `${JSON.stringify(unknown.slice(0, 50))} cannot be changed here`, { hint: "Patch title, description, tags or metadata; archive and restore have their own routes" })
198
+ if (keys.length === 0) throw new AgentError("invalid", "Nothing to change")
199
+ if (input.title !== undefined && !isLabel(input.title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
200
+ if (input.description !== undefined && input.description !== null && !isText(input.description, DESCRIPTION_MAX))
201
+ throw new AgentError("invalid", `description must be at most ${DESCRIPTION_MAX} characters`)
202
+ const problem = (input.tags === null ? undefined : tagsProblem(input.tags)) ?? (input.metadata === null ? undefined : metadataProblem(input.metadata))
203
+ if (problem) throw new AgentError("invalid", problem)
204
+ return input as WorkspacePatch
205
+ }
206
+
207
+ function checkExpected(expectedRevision: number | undefined): void {
208
+ if (expectedRevision !== undefined && !isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
209
+ }
210
+
211
+ /** Canonical JSON (sorted keys) for comparing request bodies. */
212
+ function canonical(v: unknown): string {
213
+ if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`
214
+ if (isObj(v))
215
+ return `{${Object.keys(v)
216
+ .sort()
217
+ .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
218
+ .join(",")}}`
219
+ return JSON.stringify(v)
220
+ }
221
+
222
+ /** sha256 of the canonical JSON of a request body (idempotency comparisons). */
223
+ export const digestOf = (body: unknown) => createHash("sha256").update(canonical(body)).digest("hex")
224
+
225
+ /** An absolute repo path without its trailing slash (the root stays "/"). */
226
+ const canonicalRepo = (repo: string) => (repo.length > 1 ? repo.replace(/\/$/, "") : repo)
227
+
228
+ const byCreation = (a: HubWorkspace, b: HubWorkspace) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
229
+
230
+ function encodeCursor(w: HubWorkspace): string {
231
+ return Buffer.from(JSON.stringify([w.createdAt, w.id])).toString("base64url")
232
+ }
233
+
234
+ function decodeCursor(cursor: string): [number, string] {
235
+ try {
236
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown
237
+ if (Array.isArray(parsed) && parsed.length === 2 && isNum(parsed[0]) && typeof parsed[1] === "string") return [parsed[0], parsed[1]]
238
+ } catch {
239
+ // answered below
240
+ }
241
+ throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
242
+ }
243
+
244
+ function bindingsDoc(raw: unknown, workspaceID: string): BindingsDoc | undefined {
245
+ if (raw === undefined) return undefined
246
+ if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION)
247
+ throw new AgentError("unsupported", "These bindings were written by a newer hub", { extra: { feature: "workspace-bindings" } })
248
+ if (!isObj(raw) || raw.v !== 1 || raw.workspaceID !== workspaceID || !Array.isArray(raw.bindings)) throw new AgentError("internal", "Stored workspace bindings are malformed")
249
+ return raw as unknown as BindingsDoc
250
+ }
251
+
252
+ export class WorkspaceStore {
253
+ readonly limits: WorkspaceStoreLimits
254
+ /** The StateStore under these records (route helpers keep their records next to them). */
255
+ readonly state: StateStore
256
+ private readonly now: Clock
257
+ private readonly newID: (kind: IdKind) => string
258
+
259
+ constructor(private readonly deps: WorkspaceStoreDeps) {
260
+ this.state = deps.state
261
+ this.now = deps.now ?? Date.now
262
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
263
+ this.limits = {
264
+ workspacesPerUser: deps.limits?.workspacesPerUser ?? WORKSPACE_LIMITS.workspacesPerUser,
265
+ bindingsPerWorkspace: deps.limits?.bindingsPerWorkspace ?? WORKSPACE_LIMITS.bindingsPerWorkspace,
266
+ }
267
+ }
268
+
269
+ // ---------- workspaces ----------
270
+
271
+ /**
272
+ * Creates an active workspace (rate_limited at the per-user cap). With an idempotency key, the same key and body
273
+ * within IDEMPOTENCY_TTL_MS return the original (created false); another body is a conflict.
274
+ */
275
+ async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ workspace: HubWorkspace; created: boolean }> {
276
+ const body = createBody(input)
277
+ const key = options.idempotencyKey
278
+ if (key === undefined) return { workspace: await this.insert(userID, this.newID("workspace"), body), created: true }
279
+ if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
280
+ const digest = digestOf(body)
281
+ await this.pruneKeys(userID)
282
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
283
+ const held = await this.state.get<IdempotencyDoc>(userID, WORKSPACE_IDEMPOTENCY, key)
284
+ const live = held && isObj(held.value) && isNum(held.value.at) && this.now() - held.value.at < IDEMPOTENCY_TTL_MS ? held.value : undefined
285
+ if (live) {
286
+ if (live.digest !== digest)
287
+ throw new AgentError("conflict", `Idempotency-Key ${key} was used for a different workspace`, { hint: "Use a new key for a different body, or resend the original body" })
288
+ const existing = await this.readSoon(userID, live.workspaceID)
289
+ if (existing) return { workspace: existing, created: false }
290
+ if (this.now() - live.at < RESERVATION_MS)
291
+ throw new AgentError("rate_limited", `The workspace for Idempotency-Key ${key} is still being created`, { extra: { retryAfter: 1 } })
292
+ // Its creator crashed before writing the workspace: take the key over.
293
+ }
294
+ const id = this.newID("workspace")
295
+ const doc: IdempotencyDoc = { v: 1, workspaceID: id, digest, at: this.now() }
296
+ const claimed = await this.state.put(userID, WORKSPACE_IDEMPOTENCY, key, doc, { ifVersion: held?.version ?? 0 })
297
+ if (!claimed.ok) continue
298
+ try {
299
+ return { workspace: await this.insert(userID, id, body), created: true }
300
+ } catch (error) {
301
+ await this.state.delete(userID, WORKSPACE_IDEMPOTENCY, key, { ifVersion: claimed.version })
302
+ throw error
303
+ }
304
+ }
305
+ throw new Error(`workspace idempotency key ${key} kept conflicting`)
306
+ }
307
+
308
+ /** The user's workspace, or undefined (also for another user's); throws for records this build cannot read. */
309
+ async get(userID: string, id: string): Promise<HubWorkspace | undefined> {
310
+ const stored = await this.state.get(userID, WORKSPACES, id)
311
+ if (!stored) return undefined
312
+ return this.usable(id, readWorkspace(id, stored.value, stored.version))
313
+ }
314
+
315
+ /** The user's workspace, or not_found. */
316
+ async require(userID: string, id: string): Promise<HubWorkspace> {
317
+ const workspace = await this.get(userID, id)
318
+ if (!workspace) throw new AgentError("not_found", `No workspace ${id}`)
319
+ return workspace
320
+ }
321
+
322
+ /** The user's readable workspaces by creation, paged (records this build cannot read are skipped). */
323
+ async list(userID: string, query: WorkspaceListQuery = {}): Promise<Page<HubWorkspace>> {
324
+ const limit = query.limit ?? LIST_LIMIT_DEFAULT
325
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
326
+ if (query.state !== undefined && !RECORD_STATES.includes(query.state as RecordState)) throw new AgentError("invalid", `state must be one of ${RECORD_STATES.join(", ")}`)
327
+ const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor)
328
+ const all = (await this.readAll(userID))
329
+ .filter((w) => query.state === undefined || w.state === query.state)
330
+ .filter((w) => !after || w.createdAt > after[0] || (w.createdAt === after[0] && w.id > after[1]))
331
+ .sort(byCreation)
332
+ const items = all.slice(0, limit)
333
+ return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!) } : { items }
334
+ }
335
+
336
+ /** Changes title, description, tags or metadata of an active workspace under its expected revision. */
337
+ async update(userID: string, id: string, patch: unknown, expectedRevision: number): Promise<HubWorkspace> {
338
+ if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
339
+ const change = patchBody(patch)
340
+ return this.mutate(userID, id, expectedRevision, (current) => {
341
+ if (current.state === "archived") throw new AgentError("invalid", `Workspace ${id} is archived`, { hint: "Restore it first" })
342
+ const next: Record<string, unknown> = { ...current }
343
+ for (const [field, value] of Object.entries(change)) {
344
+ if (value === null) delete next[field]
345
+ else next[field] = value
346
+ }
347
+ return next as unknown as HubWorkspace
348
+ })
349
+ }
350
+
351
+ /** Archives the workspace (reversible; its bindings and records stay). Archiving an archived one is a no-op. */
352
+ async archive(userID: string, id: string, expectedRevision?: number): Promise<HubWorkspace> {
353
+ checkExpected(expectedRevision)
354
+ const archived = await this.mutate(userID, id, expectedRevision, (current) => (current.state === "archived" ? undefined : { ...current, state: "archived" }))
355
+ await this.unreserve(userID, id)
356
+ return archived
357
+ }
358
+
359
+ /** Makes an archived workspace active again (rate_limited at the cap). Restoring an active one is a no-op. */
360
+ async restore(userID: string, id: string, expectedRevision?: number): Promise<HubWorkspace> {
361
+ checkExpected(expectedRevision)
362
+ const current = await this.require(userID, id)
363
+ if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Workspace ${id}`, current.revision)
364
+ if (current.state === "active") return current
365
+ await this.reserve(userID, id)
366
+ try {
367
+ return await this.mutate(userID, id, expectedRevision, (w) => (w.state === "active" ? undefined : { ...w, state: "active" }))
368
+ } catch (error) {
369
+ await this.unreserve(userID, id)
370
+ throw error
371
+ }
372
+ }
373
+
374
+ /** Records (or clears) the current master membership; HWA-04/12 own the membership semantics. */
375
+ async setMaster(userID: string, id: string, masterMemberID: string | undefined, expectedRevision?: number): Promise<HubWorkspace> {
376
+ checkExpected(expectedRevision)
377
+ if (masterMemberID !== undefined && !isContractID("member", masterMemberID)) throw new AgentError("invalid", "masterMemberID must be a workspace member ID")
378
+ return this.mutate(userID, id, expectedRevision, (current) => {
379
+ if (current.masterMemberID === masterMemberID) return undefined
380
+ const { masterMemberID: _old, ...rest } = current
381
+ return masterMemberID === undefined ? rest : { ...rest, masterMemberID }
382
+ })
383
+ }
384
+
385
+ /** Makes buffered writes durable (standalone debounce); a no-op for write-through stores. */
386
+ async flush(): Promise<void> {
387
+ await this.state.flush?.()
388
+ }
389
+
390
+ // ---------- host repository bindings ----------
391
+
392
+ /** The workspace's bindings (not_found for a missing or another user's workspace). */
393
+ async bindings(userID: string, workspaceID: string): Promise<HostRepoBinding[]> {
394
+ await this.require(userID, workspaceID)
395
+ const doc = bindingsDoc((await this.state.get(userID, WORKSPACE_BINDINGS, workspaceID))?.value, workspaceID)
396
+ return [...(doc?.bindings ?? [])]
397
+ }
398
+
399
+ /**
400
+ * Binds the workspace to a repository on one of the user's machines. The same machine + canonical repo again
401
+ * returns the existing binding (created false); with another label it is a conflict.
402
+ */
403
+ async bind(userID: string, workspaceID: string, input: unknown): Promise<{ binding: HostRepoBinding; created: boolean }> {
404
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
405
+ const { machineID, repo: rawRepo, label } = input
406
+ if (typeof machineID !== "string" || !MACHINE_ID.test(machineID)) throw new AgentError("invalid", "machineID must be a machine ID")
407
+ if (!isAbsolutePath(rawRepo)) throw new AgentError("invalid", "repo must be an absolute path without . or .. segments")
408
+ if (label !== undefined && !isLabel(label)) throw new AgentError("invalid", "label must be 1–120 characters of text")
409
+ const repo = canonicalRepo(rawRepo)
410
+ const workspace = await this.require(userID, workspaceID)
411
+ if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
412
+ if (!(await this.deps.ownsMachine(userID, machineID))) throw new AgentError("not_found", `No machine ${machineID}`)
413
+ let result: { binding: HostRepoBinding; created: boolean } | undefined
414
+ await updateVersioned<BindingsDoc>(this.state, userID, WORKSPACE_BINDINGS, workspaceID, (raw) => {
415
+ const list = bindingsDoc(raw, workspaceID)?.bindings ?? []
416
+ const same = list.find((b) => b.machineID === machineID && b.repo === repo)
417
+ if (same) {
418
+ if (same.label !== label) throw new AgentError("conflict", `${repo} on ${machineID} is already bound with another label`, { extra: { currentRevision: same.revision } })
419
+ result = { binding: same, created: false }
420
+ return undefined
421
+ }
422
+ if (list.length >= this.limits.bindingsPerWorkspace)
423
+ throw new AgentError("rate_limited", `A workspace has at most ${this.limits.bindingsPerWorkspace} repository bindings`, { hint: "Remove a binding first" })
424
+ const binding: HostRepoBinding = { v: 1, id: this.newID("binding"), workspaceID, machineID, repo, ...(label !== undefined ? { label } : {}), revision: 1 }
425
+ result = { binding, created: true }
426
+ return { v: 1, workspaceID, bindings: [...list, binding] }
427
+ })
428
+ return result!
429
+ }
430
+
431
+ /** Removes a binding (optionally only at the expected revision); not_found when it does not exist. */
432
+ async unbind(userID: string, workspaceID: string, bindingID: string, expectedRevision?: number): Promise<HostRepoBinding> {
433
+ checkExpected(expectedRevision)
434
+ await this.require(userID, workspaceID)
435
+ let removed: HostRepoBinding | undefined
436
+ await updateVersioned<BindingsDoc>(this.state, userID, WORKSPACE_BINDINGS, workspaceID, (raw) => {
437
+ const list = bindingsDoc(raw, workspaceID)?.bindings ?? []
438
+ const binding = list.find((b) => b.id === bindingID)
439
+ if (!binding) throw new AgentError("not_found", `No binding ${bindingID} in workspace ${workspaceID}`)
440
+ if (expectedRevision !== undefined && binding.revision !== expectedRevision) throw conflict(`Binding ${bindingID}`, binding.revision)
441
+ removed = binding
442
+ return { v: 1, workspaceID, bindings: list.filter((b) => b.id !== bindingID) }
443
+ })
444
+ return removed!
445
+ }
446
+
447
+ /** Every binding of the user on one machine, with its workspace (for placement and offline reporting). */
448
+ async bindingsForMachine(userID: string, machineID: string): Promise<MachineBinding[]> {
449
+ const docs = await this.state.list(userID, WORKSPACE_BINDINGS)
450
+ const out: MachineBinding[] = []
451
+ for (const entry of docs) {
452
+ let doc: BindingsDoc | undefined
453
+ try {
454
+ doc = bindingsDoc(entry.value, entry.key)
455
+ } catch {
456
+ continue
457
+ }
458
+ for (const binding of doc?.bindings ?? []) if (binding.machineID === machineID) out.push({ workspaceID: entry.key, binding })
459
+ }
460
+ return out
461
+ }
462
+
463
+ // ---------- internals ----------
464
+
465
+ private usable(id: string, read: Readout): HubWorkspace {
466
+ if (read.ok) return read.workspace
467
+ if (read.why === "newer")
468
+ throw new AgentError("unsupported", `Workspace ${id} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "workspaces", recordVersion: read.v } })
469
+ throw new AgentError("internal", `Stored workspace ${id} is malformed`)
470
+ }
471
+
472
+ private async readAll(userID: string): Promise<HubWorkspace[]> {
473
+ const out: HubWorkspace[] = []
474
+ for (const entry of await this.state.list(userID, WORKSPACES)) {
475
+ const read = readWorkspace(entry.key, entry.value, entry.version)
476
+ if (read.ok) out.push(read.workspace)
477
+ }
478
+ return out
479
+ }
480
+
481
+ /** A workspace a concurrent creator may be about to write: re-read a few times. */
482
+ private async readSoon(userID: string, id: string): Promise<HubWorkspace | undefined> {
483
+ for (let i = 0; i < READS; i++) {
484
+ const workspace = await this.get(userID, id)
485
+ if (workspace) return workspace
486
+ }
487
+ return undefined
488
+ }
489
+
490
+ /** Version-checked change of a workspace; `change` returning undefined leaves it alone. */
491
+ private async mutate(userID: string, id: string, expectedRevision: number | undefined, change: (current: HubWorkspace) => HubWorkspace | undefined): Promise<HubWorkspace> {
492
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
493
+ const current = await this.require(userID, id)
494
+ if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Workspace ${id}`, current.revision)
495
+ const next = change(current)
496
+ if (!next) return current
497
+ const written: HubWorkspace = { ...next, v: 1, id, createdAt: current.createdAt, revision: current.revision + 1, updatedAt: this.now() }
498
+ const result = await this.state.put(userID, WORKSPACES, id, written, { ifVersion: current.revision })
499
+ if (result.ok) return { ...written, revision: result.version }
500
+ if (expectedRevision !== undefined) throw conflict(`Workspace ${id}`, result.version)
501
+ }
502
+ throw new Error(`workspace ${id} kept conflicting`)
503
+ }
504
+
505
+ private async insert(userID: string, id: string, body: Pick<HubWorkspace, "title" | "description" | "tags" | "metadata">): Promise<HubWorkspace> {
506
+ await this.reserve(userID, id)
507
+ const at = this.now()
508
+ const workspace: HubWorkspace = { v: 1, id, ...body, state: "active", revision: 1, createdAt: at, updatedAt: at }
509
+ const written = await this.state.put(userID, WORKSPACES, id, workspace, { ifVersion: 0 })
510
+ if (!written.ok) {
511
+ await this.unreserve(userID, id)
512
+ throw new Error(`workspace id ${id} already exists`)
513
+ }
514
+ return workspace
515
+ }
516
+
517
+ /** Takes an active slot for `id` (rate_limited when the user is at the cap even after rebuilding the index). */
518
+ private async reserve(userID: string, id: string): Promise<void> {
519
+ const missing = (await this.state.get(userID, WORKSPACE_INDEX, ACTIVE)) === undefined
520
+ if (await this.tryReserve(userID, id, missing)) return
521
+ if (!missing && (await this.tryReserve(userID, id, true))) return
522
+ throw new AgentError("rate_limited", `A user has at most ${this.limits.workspacesPerUser} active workspaces`, { hint: "Archive a workspace first" })
523
+ }
524
+
525
+ private async tryReserve(userID: string, id: string, rebuild: boolean): Promise<boolean> {
526
+ const records = rebuild ? await this.state.list(userID, WORKSPACES) : undefined
527
+ let reserved = false
528
+ await updateVersioned<IndexDoc>(this.state, userID, WORKSPACE_INDEX, ACTIVE, (raw) => {
529
+ const stored = isObj(raw) && Array.isArray(raw.entries) ? (raw.entries as IndexEntry[]) : []
530
+ const entries = records ? this.rebuilt(stored, records) : stored
531
+ reserved = entries.some((e) => e.id === id)
532
+ if (!reserved && entries.length < this.limits.workspacesPerUser) {
533
+ reserved = true
534
+ return { v: 1, entries: [...entries, { id, at: this.now() }] }
535
+ }
536
+ return records ? { v: 1, entries } : undefined
537
+ })
538
+ return reserved
539
+ }
540
+
541
+ /** The index from the records: active (or unreadable-newer) ones count, plus fresh reservations. */
542
+ private rebuilt(stored: readonly IndexEntry[], records: readonly { key: string; value: unknown; version: number }[]): IndexEntry[] {
543
+ const live = new Map<string, number>()
544
+ for (const record of records) {
545
+ const read = readWorkspace(record.key, record.value, record.version)
546
+ if (read.ok ? read.workspace.state === "active" : read.why === "newer") live.set(record.key, read.ok ? read.workspace.createdAt : this.now())
547
+ }
548
+ const now = this.now()
549
+ const entries = stored.filter((e) => isObj(e) && typeof e.id === "string" && isNum(e.at) && (live.has(e.id) || now - e.at < RESERVATION_MS))
550
+ const have = new Set(entries.map((e) => e.id))
551
+ for (const [id, at] of live) if (!have.has(id)) entries.push({ id, at })
552
+ return entries
553
+ }
554
+
555
+ private async unreserve(userID: string, id: string): Promise<void> {
556
+ await updateVersioned<IndexDoc>(this.state, userID, WORKSPACE_INDEX, ACTIVE, (raw) => {
557
+ if (!isObj(raw) || !Array.isArray(raw.entries)) return undefined
558
+ const entries = (raw.entries as IndexEntry[]).filter((e) => e.id !== id)
559
+ return entries.length === raw.entries.length ? undefined : { v: 1, entries }
560
+ })
561
+ }
562
+
563
+ /** Drops expired idempotency keys. */
564
+ private async pruneKeys(userID: string): Promise<void> {
565
+ const now = this.now()
566
+ for (const entry of await this.state.list<IdempotencyDoc>(userID, WORKSPACE_IDEMPOTENCY)) {
567
+ const at = isObj(entry.value) && isNum(entry.value.at) ? entry.value.at : 0
568
+ if (now - at >= IDEMPOTENCY_TTL_MS) await this.state.delete(userID, WORKSPACE_IDEMPOTENCY, entry.key, { ifVersion: entry.version })
569
+ }
570
+ }
571
+ }