@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,490 @@
1
+ // Explicit master replacement and lifetime recovery (HWA-12; contract: Workspaces `POST /workspaces/:id/master
2
+ // {sessionID, expectedRevision}` in docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md and its
3
+ // clarifications from HWA-04, HWA-05, HWA-06 and HWA-11).
4
+ //
5
+ // What a replacement does, in this order:
6
+ // 1. Checks the workspace record's `expectedRevision` (stale: 409 conflict with currentRevision) and takes the
7
+ // workspace's replacement fence: one record per workspace in the StateStore collection `workspace-master`,
8
+ // claimed by a version-checked write. Of two concurrent replacements exactly one holds the fence; the other is
9
+ // a 409 conflict. After the claim the workspace revision is checked again, so a replacement that finished
10
+ // between the first read and the claim fences the late one out as well.
11
+ // 2. MembershipStore.join(…, {allowMaster, replaces}): ONE write of the members aggregate departs the old master
12
+ // as `replaced` and adds the new master member with the old generation + 1 (no current master: one more than
13
+ // any earlier master of the workspace, so the master slot's generation never goes back).
14
+ // 3. WorkspaceStore.setMaster(new member) under the expected revision: the commit point. Steps 2 and 3 are two
15
+ // records (the StateStore has no cross-record transactions), so the fence records the new member before step
16
+ // 3. A crash or a lost race (an unrelated PATCH moved the revision) rolls FORWARD: the master record is set to
17
+ // the already joined member under a fresh read, as long as it still names the old master or nobody. Nothing
18
+ // is undone, because the old membership is already departed.
19
+ // 4. SupervisionStore.memberDeparted(old, {replacedBy: new}) AFTER masterMemberID names the new member, so the
20
+ // old master's supervised members move to the new one and the new master never ends up with a supervisor.
21
+ // 5. Releases the fence, flushes the StateStore; the route appends compact events: `workspace.member.left`
22
+ // (replaced), `workspace.member.joined`, `workspace.master.replaced` (IDs, generations, revisions, counts).
23
+ // A fence whose holder crashed is finished (rolled forward or released) by the next replacement or read once it is
24
+ // older than MASTER_FENCE_MS; until then a replacement is a 409 conflict with a retry hint.
25
+ //
26
+ // Fencing an old master: member IDs are minted per generation, so everything keyed by the old master member (its
27
+ // checkpoints, its digest ledger, its supervision links) stops applying the moment it departs: checkpoint acks
28
+ // naming it are refused (it left), the coordinator revokes its ledger on its next pass, and assertMaster() refuses
29
+ // anything that names a master member or generation that is no longer current (409 conflict with the current one).
30
+ //
31
+ // Board and checkpoint continuity: the board is derived from live records, so it simply shows the new master.
32
+ // Checkpoints stay per supervisor member and generation: the new master does NOT inherit the old master's
33
+ // checkpoint cursor. A cursor acknowledges what one session processed; the new session has not seen those events,
34
+ // so the coordinator starts it with a fresh snapshot (members, presence, unresolved assignments, revisions). The old
35
+ // master's checkpoints are kept (GET …/checkpoints?supervisorMemberID=<old>) and the replacement answer carries the
36
+ // old master's latest one (`checkpoints.previousCheckpoint`, without the internal journal position).
37
+ //
38
+ // Grants: none move. Grants held by or given to the old master's session stay exactly as they are; the new master's
39
+ // session needs its own explicit grants (the answer says so in `grants`). Membership and the master role grant
40
+ // nothing. Native parentID/rootID are never touched.
41
+ //
42
+ // Sessions and harness restarts: the new master may be any session the user's directory knows, online or offline
43
+ // (presence is reported, never hidden); an unknown session is not_found and a deleted one invalid. The old
44
+ // master's presence never blocks a replacement (an offline or vanished master can always be replaced by the user).
45
+ // A harness that resumes the same session (same session ID) keeps its membership: nothing to replace. A restart that
46
+ // makes a new session (Claude Code and Codex cannot resume a lost process as the same session) cannot own the
47
+ // workspace's lifetime: when the old session is deleted the hub departs it (`ended`) and the workspace stays,
48
+ // without a master, until the user replaces it. `launchID` instead of `sessionID` names the session of one of the
49
+ // user's running launches (the existing launch API starts it under its own authority; nothing is launched here).
50
+ //
51
+ // Rollback: older hubs never read `workspace-master`; workspace and member records keep their shapes.
52
+ import { randomBytes } from "node:crypto"
53
+ import { CONTRACT_VERSION, type HubWorkspace, isExpectedRevision, WORKSPACE_SCOPES, type WorkspaceMember } from "../../protocol"
54
+ import type { AgentContext, AgentRoute } from "../agent/routes"
55
+ import type { AgentEvents, EventInput } from "../agent/events"
56
+ import { AgentError } from "../agent/errors"
57
+ import { idempotencyKeyOf, RouteIdempotency } from "../agent/idempotency"
58
+ import type { Clock, StateStore } from "../backend/types"
59
+ import type { CheckpointStore, StoredCheckpoint } from "./checkpoints"
60
+ import { memberJoinedEvent, memberLeftEvent, supervisionDepartedEvent } from "./events"
61
+ import { type MembershipStore, SESSION_ID, type SessionPresence } from "./members"
62
+ import { WORKSPACE_ROUTE_IDEMPOTENCY } from "./routes"
63
+ import type { WorkspaceStore } from "./store"
64
+ import type { DepartureResult, SupervisionStore } from "./supervision"
65
+ import { AGENT_JSON_BODY_MAX, boundedText } from "../agent/body"
66
+
67
+ export const WORKSPACE_MASTER = "workspace-master"
68
+ /** A replacement fence older than this belongs to a crashed holder and is finished by the next caller. */
69
+ export const MASTER_FENCE_MS = 60_000
70
+ /** Earlier masters listed by GET /workspaces/:id/master. */
71
+ export const PREVIOUS_MASTERS_MAX = 10
72
+ const ATTEMPTS = 16
73
+ const LAUNCH_ID = /^[A-Za-z0-9_-]{1,128}$/
74
+
75
+ export const GRANTS_NOTE = "Grants are not transferred: grants held by or given to the previous master stay as they are; the new master's session needs its own explicit grants."
76
+
77
+ /** The launch API's read (LaunchEngine.view). */
78
+ export interface MasterLaunches {
79
+ view(userID: string, id: string): Promise<{ readonly state: string; readonly sessionID?: string } | undefined>
80
+ }
81
+
82
+ export interface MasterStoreDeps {
83
+ readonly state: StateStore
84
+ readonly workspaces: WorkspaceStore
85
+ readonly members: MembershipStore
86
+ readonly supervision: SupervisionStore
87
+ readonly checkpoints?: Pick<CheckpointStore, "latest">
88
+ readonly launches?: MasterLaunches
89
+ readonly now?: Clock
90
+ readonly newOpID?: () => string
91
+ }
92
+
93
+ /** The replacement fence of one workspace. `phase: "done"` is a released fence (kept so versions only grow). */
94
+ interface Fence {
95
+ readonly v: 1
96
+ readonly workspaceID: string
97
+ readonly opID: string
98
+ readonly phase: "claimed" | "joined" | "done"
99
+ readonly at: number
100
+ readonly sessionID: string
101
+ readonly previousMemberID?: string
102
+ readonly newMemberID?: string
103
+ }
104
+ type HeldFence = Fence & { readonly version: number }
105
+
106
+ export type PublicCheckpoint = Omit<StoredCheckpoint, "position">
107
+
108
+ export interface MasterReplacement {
109
+ readonly workspace: HubWorkspace
110
+ readonly master: WorkspaceMember
111
+ readonly presence: SessionPresence
112
+ readonly previous?: WorkspaceMember
113
+ readonly previousPresence?: SessionPresence
114
+ /** False when the session already was the current master (nothing changed). */
115
+ readonly changed: boolean
116
+ /** The old master's links, moved to the new master or detached. */
117
+ readonly supervision?: DepartureResult
118
+ /** The new master starts from a fresh snapshot; the old master's latest checkpoint for reference. */
119
+ readonly checkpoints: { readonly inherited: false; readonly previousCheckpoint?: PublicCheckpoint }
120
+ readonly grants: { readonly transferred: false; readonly note: string }
121
+ }
122
+
123
+ export interface MasterView {
124
+ readonly workspaceID: string
125
+ readonly revision: number
126
+ readonly master?: { readonly member: WorkspaceMember; readonly presence: SessionPresence }
127
+ /** Earlier masters, newest first (bounded). */
128
+ readonly previous: readonly { readonly member: WorkspaceMember; readonly presence: SessionPresence }[]
129
+ /** A replacement in flight (or one a crashed holder left behind: `stale`). */
130
+ readonly pending?: { readonly phase: "claimed" | "joined"; readonly at: number; readonly stale: boolean }
131
+ }
132
+
133
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
134
+
135
+ const conflict = (message: string, extra: Record<string, unknown>, hint = "Read the workspace again and retry with its current revision") =>
136
+ new AgentError("conflict", message, { hint, extra })
137
+
138
+ const revisionConflict = (workspace: HubWorkspace) =>
139
+ conflict(`Workspace ${workspace.id} changed since you read it (revision ${workspace.revision} now)`, { currentRevision: workspace.revision })
140
+
141
+ const publicCheckpoint = ({ position: _position, ...rest }: StoredCheckpoint): PublicCheckpoint => rest
142
+
143
+ const isConflict = (error: unknown) => error instanceof AgentError && error.code === "conflict"
144
+
145
+ function readFence(raw: unknown, workspaceID: string): Fence | undefined {
146
+ if (raw === undefined) return undefined
147
+ if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
148
+ throw new AgentError("unsupported", `Workspace ${workspaceID} master state was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: WORKSPACE_MASTER } })
149
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || typeof raw.opID !== "string") throw new AgentError("internal", "Stored workspace master state is malformed")
150
+ return raw as unknown as Fence
151
+ }
152
+
153
+ export class MasterStore {
154
+ private readonly now: Clock
155
+ private readonly newOpID: () => string
156
+
157
+ constructor(private readonly deps: MasterStoreDeps) {
158
+ this.now = deps.now ?? Date.now
159
+ this.newOpID = deps.newOpID ?? (() => `mop_${randomBytes(10).toString("hex")}`)
160
+ }
161
+
162
+ /**
163
+ * Replaces (or first sets) the workspace's master: `{sessionID | launchID, expectedRevision}` where
164
+ * expectedRevision is the workspace record's revision. See the header for the steps and their guarantees.
165
+ */
166
+ async replace(userID: string, workspaceID: string, input: unknown): Promise<MasterReplacement> {
167
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
168
+ for (const key of Object.keys(input))
169
+ if (!["sessionID", "launchID", "expectedRevision"].includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
170
+ const { expectedRevision } = input
171
+ if (expectedRevision === undefined) throw new AgentError("invalid", "expectedRevision is required: the workspace revision you read", { hint: "Read the workspace and send its revision" })
172
+ if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
173
+ const sessionID = await this.targetSession(userID, input)
174
+
175
+ await this.recover(userID, workspaceID)
176
+ const workspace = await this.deps.workspaces.require(userID, workspaceID)
177
+ if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
178
+ if (workspace.revision !== expectedRevision) throw revisionConflict(workspace)
179
+
180
+ const all = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
181
+ const previousView = workspace.masterMemberID === undefined ? undefined : all.find((v) => v.current && v.member.id === workspace.masterMemberID)
182
+ if (previousView && previousView.member.sessionID === sessionID)
183
+ return { workspace, master: previousView.member, presence: previousView.presence, changed: false, checkpoints: { inherited: false }, grants: { transferred: false, note: GRANTS_NOTE } }
184
+ const other = all.find((v) => v.current && v.member.sessionID === sessionID)
185
+ if (other)
186
+ throw new AgentError("invalid", `Session ${sessionID} is already a ${other.member.role} member of this workspace`, {
187
+ hint: "Remove that membership first; a master is always a new membership",
188
+ extra: { memberID: other.member.id },
189
+ })
190
+ const previous = previousView?.member
191
+ const minGeneration = 1 + Math.max(0, ...all.filter((v) => v.member.role === "master").map((v) => v.member.generation))
192
+
193
+ // 1. the fence, then the revision again under it
194
+ const fence = await this.claim(userID, workspaceID, sessionID, previous?.id)
195
+ let master: WorkspaceMember
196
+ try {
197
+ const fenced = await this.deps.workspaces.require(userID, workspaceID)
198
+ if (fenced.revision !== expectedRevision) throw revisionConflict(fenced)
199
+ // 2. one members write: the old master replaced, the new one at generation + 1
200
+ const options = { allowMaster: true, minGeneration, ...(previous ? { replaces: previous.id } : {}) }
201
+ master = (await this.deps.members.join(userID, workspaceID, { sessionID, role: "master" }, options)).member
202
+ } catch (error) {
203
+ await this.release(userID, workspaceID, fence)
204
+ throw error
205
+ }
206
+ const joined = await this.advance(userID, workspaceID, fence, { phase: "joined", newMemberID: master.id })
207
+ // 3. the commit point (rolled forward on a lost race), 4. supervision follows the new master
208
+ const committed = await this.commit(userID, workspaceID, joined, expectedRevision)
209
+ const supervision = previous ? await this.deps.supervision.memberDeparted(userID, workspaceID, previous.id, { replacedBy: master.id }) : undefined
210
+ await this.release(userID, workspaceID, joined)
211
+ await this.deps.state.flush?.()
212
+
213
+ const departed = previous ? await this.deps.members.member(userID, workspaceID, previous.id) : undefined
214
+ const latest = previous ? await this.deps.checkpoints?.latest(userID, workspaceID, previous.id) : undefined
215
+ const presence = (await this.deps.members.members(userID, workspaceID)).find((v) => v.member.id === master.id)?.presence ?? "unknown"
216
+ return {
217
+ workspace: committed,
218
+ master,
219
+ presence,
220
+ ...(departed && previousView ? { previous: departed, previousPresence: previousView.presence } : {}),
221
+ changed: true,
222
+ ...(supervision ? { supervision } : {}),
223
+ checkpoints: { inherited: false, ...(latest ? { previousCheckpoint: publicCheckpoint(latest) } : {}) },
224
+ grants: { transferred: false, note: GRANTS_NOTE },
225
+ }
226
+ }
227
+
228
+ /** The current master with presence, earlier masters and any replacement in flight. */
229
+ async view(userID: string, workspaceID: string): Promise<MasterView> {
230
+ await this.recover(userID, workspaceID)
231
+ const workspace = await this.deps.workspaces.require(userID, workspaceID)
232
+ const all = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
233
+ const current = all.find((v) => v.current && v.member.id === workspace.masterMemberID)
234
+ const previous = all
235
+ .filter((v) => v.member.role === "master" && v !== current)
236
+ .sort((a, b) => (b.member.leftAt ?? b.member.joinedAt) - (a.member.leftAt ?? a.member.joinedAt))
237
+ .slice(0, PREVIOUS_MASTERS_MAX)
238
+ .map(({ member, presence }) => ({ member, presence }))
239
+ const fence = readFence((await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID))?.value, workspaceID)
240
+ const pending = fence && fence.phase !== "done" ? { phase: fence.phase, at: fence.at, stale: this.now() - fence.at >= MASTER_FENCE_MS } : undefined
241
+ return {
242
+ workspaceID,
243
+ revision: workspace.revision,
244
+ ...(current ? { master: { member: current.member, presence: current.presence } } : {}),
245
+ previous,
246
+ ...(pending ? { pending } : {}),
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Refuses a stale master: `memberID` (and `generation`, when given) must be the workspace's current master.
252
+ * 409 conflict naming the current master member and generation otherwise. For master-only operations.
253
+ */
254
+ async assertMaster(userID: string, workspaceID: string, memberID: string, generation?: number): Promise<WorkspaceMember> {
255
+ const workspace = await this.deps.workspaces.require(userID, workspaceID)
256
+ const named = workspace.masterMemberID === undefined ? undefined : await this.deps.members.member(userID, workspaceID, workspace.masterMemberID).catch(() => undefined)
257
+ const live = named && named.leftAt === undefined ? named : undefined
258
+ if (!live || live.id !== memberID || (generation !== undefined && live.generation !== generation))
259
+ throw conflict(
260
+ `Member ${memberID} is not the current master of workspace ${workspaceID}`,
261
+ { ...(live ? { currentMasterMemberID: live.id, currentGeneration: live.generation } : {}), currentRevision: workspace.revision },
262
+ "The master was replaced: act as the current master or replace it explicitly",
263
+ )
264
+ return live
265
+ }
266
+
267
+ /**
268
+ * Finishes a replacement whose holder crashed (fence older than MASTER_FENCE_MS): rolls a joined member forward
269
+ * to master, or releases a fence that never joined. Fresh fences are left alone (`busy`).
270
+ */
271
+ async recover(userID: string, workspaceID: string): Promise<"none" | "busy" | "rolled_forward" | "released"> {
272
+ const fence = readFence((await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID))?.value, workspaceID)
273
+ if (!fence || fence.phase === "done") return "none"
274
+ if (this.now() - fence.at < MASTER_FENCE_MS) return "busy"
275
+ let newMemberID = fence.newMemberID
276
+ if (newMemberID === undefined) {
277
+ // The join may have landed before the crash without the fence recording it.
278
+ const views = await this.deps.members.members(userID, workspaceID)
279
+ newMemberID = views.find((v) => v.member.role === "master" && v.member.sessionID === fence.sessionID && v.member.joinedAt >= fence.at)?.member.id
280
+ }
281
+ let outcome: "rolled_forward" | "released" = "released"
282
+ if (newMemberID !== undefined) {
283
+ const joined = await this.deps.members.member(userID, workspaceID, newMemberID).catch(() => undefined)
284
+ if (joined && joined.leftAt === undefined) {
285
+ await this.commit(userID, workspaceID, { ...fence, newMemberID }, undefined)
286
+ if (fence.previousMemberID) await this.deps.supervision.memberDeparted(userID, workspaceID, fence.previousMemberID, { replacedBy: newMemberID })
287
+ outcome = "rolled_forward"
288
+ }
289
+ }
290
+ await this.release(userID, workspaceID, fence)
291
+ await this.deps.state.flush?.()
292
+ return outcome
293
+ }
294
+
295
+ // ---------- internals ----------
296
+
297
+ private async targetSession(userID: string, input: Record<string, unknown>): Promise<string> {
298
+ const { sessionID, launchID } = input
299
+ if ((sessionID === undefined) === (launchID === undefined)) throw new AgentError("invalid", "Send exactly one of sessionID or launchID")
300
+ if (sessionID !== undefined) {
301
+ if (typeof sessionID !== "string" || !SESSION_ID.test(sessionID)) throw new AgentError("invalid", "sessionID must be a session ID (ses_…)")
302
+ return sessionID
303
+ }
304
+ if (typeof launchID !== "string" || !LAUNCH_ID.test(launchID)) throw new AgentError("invalid", "launchID must be a launch ID")
305
+ if (!this.deps.launches) throw new AgentError("unsupported", "This hub does not serve launches", { extra: { feature: "launches" } })
306
+ const launch = await this.deps.launches.view(userID, launchID)
307
+ if (!launch) throw new AgentError("not_found", `No launch ${launchID}`)
308
+ if (launch.state !== "running" || launch.sessionID === undefined)
309
+ throw new AgentError("invalid", `Launch ${launchID} is ${launch.state}, not running`, {
310
+ hint: launch.state === "ended" ? "Launch a new session" : "Wait until it is running",
311
+ extra: { launchState: launch.state },
312
+ })
313
+ return launch.sessionID
314
+ }
315
+
316
+ /** Takes the workspace's fence (409 conflict while another replacement holds a fresh one). */
317
+ private async claim(userID: string, workspaceID: string, sessionID: string, previousMemberID: string | undefined): Promise<HeldFence> {
318
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
319
+ const stored = await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID)
320
+ const held = readFence(stored?.value, workspaceID)
321
+ if (held && held.phase !== "done") {
322
+ if (this.now() - held.at < MASTER_FENCE_MS)
323
+ throw conflict(
324
+ `Another master replacement of workspace ${workspaceID} is in progress`,
325
+ { retryAfter: Math.max(1, Math.ceil((held.at + MASTER_FENCE_MS - this.now()) / 1000)) },
326
+ "Read the workspace again shortly",
327
+ )
328
+ await this.recover(userID, workspaceID)
329
+ continue
330
+ }
331
+ const fence: Fence = { v: 1, workspaceID, opID: this.newOpID(), phase: "claimed", at: this.now(), sessionID, ...(previousMemberID ? { previousMemberID } : {}) }
332
+ const put = await this.deps.state.put(userID, WORKSPACE_MASTER, workspaceID, fence, { ifVersion: stored?.version ?? 0 })
333
+ if (put.ok) return { ...fence, version: put.version }
334
+ // Another writer got there first: the next round reads its fence.
335
+ }
336
+ throw new Error(`state update of ${WORKSPACE_MASTER}/${workspaceID} kept conflicting`)
337
+ }
338
+
339
+ /** Records progress under the fence; a fence taken over meanwhile (only after MASTER_FENCE_MS) is a conflict. */
340
+ private async advance(userID: string, workspaceID: string, fence: HeldFence, change: Partial<Fence>): Promise<HeldFence> {
341
+ const { version, ...rest } = fence
342
+ const next: Fence = { ...rest, ...change }
343
+ const put = await this.deps.state.put(userID, WORKSPACE_MASTER, workspaceID, next, { ifVersion: version })
344
+ if (!put.ok) throw conflict(`The master replacement of workspace ${workspaceID} was taken over`, {}, "Read the workspace again")
345
+ return { ...next, version: put.version }
346
+ }
347
+
348
+ /** Releases the fence (phase done) if it is still this operation's. */
349
+ private async release(userID: string, workspaceID: string, fence: Fence): Promise<void> {
350
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
351
+ const stored = await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID)
352
+ const current = readFence(stored?.value, workspaceID)
353
+ if (!stored || !current || current.opID !== fence.opID || current.phase === "done") return
354
+ const done: Fence = { ...current, phase: "done", at: this.now() }
355
+ if ((await this.deps.state.put(userID, WORKSPACE_MASTER, workspaceID, done, { ifVersion: stored.version })).ok) return
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Points masterMemberID at the joined member: under the caller's expected revision first, then (rolled forward)
361
+ * under fresh reads while the record still names the old master or nobody. Another master there means someone
362
+ * else committed: a conflict.
363
+ */
364
+ private async commit(userID: string, workspaceID: string, fence: Fence, expectedRevision: number | undefined): Promise<HubWorkspace> {
365
+ const target = fence.newMemberID!
366
+ if (expectedRevision !== undefined) {
367
+ try {
368
+ return await this.deps.workspaces.setMaster(userID, workspaceID, target, expectedRevision)
369
+ } catch (error) {
370
+ if (!isConflict(error)) throw error
371
+ }
372
+ }
373
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
374
+ const current = await this.deps.workspaces.require(userID, workspaceID)
375
+ if (current.masterMemberID === target) return current
376
+ if (current.masterMemberID !== undefined && current.masterMemberID !== fence.previousMemberID)
377
+ throw conflict(`Workspace ${workspaceID} has another master now`, { currentRevision: current.revision, currentMasterMemberID: current.masterMemberID })
378
+ try {
379
+ return await this.deps.workspaces.setMaster(userID, workspaceID, target, current.revision)
380
+ } catch (error) {
381
+ if (!isConflict(error)) throw error
382
+ }
383
+ }
384
+ throw new Error(`workspace ${workspaceID} master kept conflicting`)
385
+ }
386
+ }
387
+
388
+ // ---------- events ----------
389
+
390
+ /** The master changed: IDs, generations, revisions and counts only. */
391
+ export function masterReplacedEvent(result: MasterReplacement): EventInput {
392
+ return {
393
+ type: "workspace.master.replaced",
394
+ family: "manage",
395
+ subject: { workspaceID: result.workspace.id, memberID: result.master.id, sessionID: result.master.sessionID, ...(result.previous ? { previousMemberID: result.previous.id } : {}) },
396
+ data: {
397
+ generation: result.master.generation,
398
+ revision: result.workspace.revision,
399
+ ...(result.previous ? { previousGeneration: result.previous.generation } : {}),
400
+ transferred: result.supervision?.transferred ?? 0,
401
+ detached: result.supervision?.detached ?? 0,
402
+ },
403
+ }
404
+ }
405
+
406
+ /** The journal events of a replacement, in order (none when nothing changed). */
407
+ export function masterEvents(result: MasterReplacement): EventInput[] {
408
+ if (!result.changed) return []
409
+ const links = result.supervision
410
+ return [
411
+ ...(result.previous ? [memberLeftEvent(result.previous)] : []),
412
+ memberJoinedEvent(result.master),
413
+ masterReplacedEvent(result),
414
+ ...(result.previous && links && links.detached + links.transferred + links.pruned > 0 ? [supervisionDepartedEvent(result.previous.id, links)] : []),
415
+ ]
416
+ }
417
+
418
+ // ---------- routes ----------
419
+
420
+ const PREFIX = "/api/agent/workspaces"
421
+ const NO_STORE = { "cache-control": "no-store" }
422
+ const [READ_SCOPE, MANAGE_SCOPE] = WORKSPACE_SCOPES
423
+
424
+ export interface MasterRoutesDeps extends Omit<MasterStoreDeps, "state"> {
425
+ /** Default: the workspaces' StateStore. */
426
+ readonly state?: StateStore
427
+ readonly events?: Pick<AgentEvents, "append">
428
+ readonly audit?: { audit(entry: { actor: string; action: string; target: string; detail?: string }): unknown }
429
+ readonly idempotency?: RouteIdempotency
430
+ readonly report?: (cause: unknown) => void
431
+ }
432
+
433
+ function sessionsVisible(context: AgentContext): void {
434
+ const { actor } = context
435
+ if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Master routes also need scope sessions:read")
436
+ }
437
+
438
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
439
+ const text = await boundedText(request, AGENT_JSON_BODY_MAX)
440
+ let raw: unknown
441
+ try {
442
+ raw = JSON.parse(text === "" ? "{}" : text)
443
+ } catch {
444
+ throw new AgentError("invalid", "Body must be JSON")
445
+ }
446
+ if (!isObj(raw)) throw new AgentError("invalid", "Body must be a JSON object")
447
+ return raw
448
+ }
449
+
450
+ /**
451
+ * GET /api/agent/workspaces/:id/master workspaces:read + sessions:read
452
+ * POST /api/agent/workspaces/:id/master {sessionID | launchID, expectedRevision} workspaces:manage + sessions:read
453
+ * (Idempotency-Key: the answer is replayed)
454
+ * Users and app tokens by scope; session tokens are refused (membership grants nothing).
455
+ */
456
+ export function masterRoutes(deps: MasterRoutesDeps): AgentRoute[] {
457
+ const state = deps.state ?? deps.workspaces.state
458
+ const store = new MasterStore({ ...deps, state })
459
+ const idempotency = deps.idempotency ?? new RouteIdempotency({ state, collection: WORKSPACE_ROUTE_IDEMPOTENCY, ...(deps.now ? { now: deps.now } : {}) })
460
+ const report = deps.report ?? ((cause: unknown) => console.error(`[hub] workspace event failed: ${cause instanceof Error ? cause.message : String(cause)}`))
461
+ return [
462
+ {
463
+ method: "GET",
464
+ path: `${PREFIX}/:id/master`,
465
+ allow: { scope: READ_SCOPE },
466
+ async handle(context) {
467
+ sessionsVisible(context)
468
+ return Response.json(await store.view(context.user.id, context.params.id!), { headers: NO_STORE })
469
+ },
470
+ },
471
+ {
472
+ method: "POST",
473
+ path: `${PREFIX}/:id/master`,
474
+ allow: { scope: MANAGE_SCOPE },
475
+ async handle(context) {
476
+ sessionsVisible(context)
477
+ const body = await bodyOf(context.request)
478
+ const userID = context.user.id
479
+ return idempotency.run(userID, `POST ${context.url.pathname}`, idempotencyKeyOf(context.request) ?? null, body, async () => {
480
+ const result = await store.replace(userID, context.params.id!, body)
481
+ if (result.changed) {
482
+ await deps.audit?.audit({ actor: userID, action: "workspace.master.replace", target: result.master.id, detail: context.actor.kind })
483
+ for (const event of masterEvents(result)) await deps.events?.append(userID, event).catch(report)
484
+ }
485
+ return { status: 200, body: result }
486
+ })
487
+ },
488
+ },
489
+ ]
490
+ }