@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,316 @@
1
+ // Durable supervisor checkpoints (HWA-11; contract: Workspaces `POST /workspaces/:id/checkpoints {cursor}` in
2
+ // docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, SupervisorCheckpoint and
3
+ // WORKSPACE_LIMITS.checkpointsPerSupervisor in packages/protocol/src/hub-workspace.ts):
4
+ //
5
+ // POST /api/agent/workspaces/:id/checkpoints {cursor, supervisorMemberID?, batchID?} workspaces:manage
6
+ // GET /api/agent/workspaces/:id/checkpoints?supervisorMemberID= workspaces:read
7
+ //
8
+ // A checkpoint says "this supervisor has processed the workspace's events up to `cursor`" (an agent-events cursor,
9
+ // hev1_…, as GET /api/agent/events and the batched digests of coordinator.ts hand out). The supervisor defaults to
10
+ // the workspace's master. Each record also pins what the acknowledgment was made against: the supervisor's member
11
+ // generation and session, the workspace and supervision-graph revisions (the snapshot revision), the digest batch
12
+ // it acknowledges (batch identity, so an uncertain delivery can be resolved, see coordinator.ts) and the unresolved
13
+ // work under that supervisor (open / in_progress / blocked assignments of the members it supervises: IDs and a
14
+ // count only, never titles). HWA-12's recovery reads these to resume a replaced master.
15
+ //
16
+ // Who: users and app tokens by scope. Session tokens are refused (contract clarification HWA-06: membership grants
17
+ // nothing; a session reading or writing its own workspace through its token is an open question owned by the user).
18
+ //
19
+ // Durability: every acknowledgment is written (one version-checked aggregate per supervisor) and then the
20
+ // StateStore is flushed BEFORE the answer, so the standalone file store's debounce cannot lose an acknowledged
21
+ // checkpoint. A failed flush is an error to the caller (nothing is claimed); a repeat flushes again.
22
+ //
23
+ // Ordering and idempotency: within one journal epoch a cursor older than the latest checkpoint is a 409 conflict;
24
+ // the same cursor with the same batch again answers the existing record (200). A cursor of another epoch (the
25
+ // standalone journal restarted, or a cluster journal was reset) is not comparable and is recorded as given. A
26
+ // cursor beyond the journal's latest position, another user's or a forged one is 400 invalid_cursor.
27
+ //
28
+ // Storage: StateStore collection `workspace-checkpoints`, key `<workspaceID>.<supervisorMemberID>`, holding the
29
+ // newest `perSupervisor` (contract: 50) records; older ones are dropped. No journal event is appended (the compact
30
+ // journal is unchanged). Rollback: older hubs never read the collection.
31
+ import { randomBytes } from "node:crypto"
32
+ import {
33
+ CONTRACT_VERSION,
34
+ ID_PREFIXES,
35
+ type IdKind,
36
+ isContractID,
37
+ LIST_LIMIT_MAX,
38
+ type SupervisorCheckpoint,
39
+ WORKSPACE_LIMITS,
40
+ WORKSPACE_SCOPES,
41
+ type HubWorkspace,
42
+ type WorkspaceMember,
43
+ } from "../../protocol"
44
+ import { type CursorKeys, decodeCursor, encodeCursor, parsePosition } from "../agent/event-cursor"
45
+ import type { AgentEvents } from "../agent/events"
46
+ import { AgentError } from "../agent/errors"
47
+ import type { AgentRoute } from "../agent/routes"
48
+ import type { Clock, StateStore } from "../backend/types"
49
+ import type { MembershipStore } from "./members"
50
+ import type { WorkspaceStore } from "./store"
51
+ import type { SupervisionStore } from "./supervision"
52
+ import { AGENT_JSON_BODY_MAX, boundedText } from "../agent/body"
53
+
54
+ export const WORKSPACE_CHECKPOINTS = "workspace-checkpoints"
55
+ /** Digest batch IDs (coordinator.ts mints them). */
56
+ export const BATCH_ID = /^dig_[0-9a-f]{20}$/
57
+ export const newBatchID = (): string => `dig_${randomBytes(10).toString("hex")}`
58
+ /** Assignment IDs a checkpoint lists as unresolved (the count is always exact). */
59
+ export const UNRESOLVED_IDS_MAX = 50
60
+ /** Version-checked write attempts before a contended acknowledgment gives up. */
61
+ const ATTEMPTS = 16
62
+ const PREFIX = "/api/agent/workspaces"
63
+ const NO_STORE = { "cache-control": "no-store" }
64
+ const [READ_SCOPE, MANAGE_SCOPE] = WORKSPACE_SCOPES
65
+ const OPEN_STATUSES: ReadonlySet<string> = new Set(["open", "in_progress", "blocked"])
66
+
67
+ export interface CheckpointLimits {
68
+ readonly perSupervisor: number
69
+ }
70
+
71
+ /** A checkpoint as stored: the contract record plus what it was made against. `position` never leaves the hub. */
72
+ export interface StoredCheckpoint extends SupervisorCheckpoint {
73
+ /** The journal position the cursor names. */
74
+ readonly position: string
75
+ readonly sessionID: string
76
+ /** The supervisor member's generation when it acknowledged. */
77
+ readonly generation: number
78
+ /** The digest batch it acknowledges, when it names one. */
79
+ readonly batchID?: string
80
+ /** The snapshot revision: the workspace record's and the supervision graph's. */
81
+ readonly snapshot: { readonly workspaceRevision: number; readonly supervisionRevision: number }
82
+ /** Not-closed assignments of the members this supervisor supervises. */
83
+ readonly unresolved: { readonly count: number; readonly assignmentIDs: readonly string[]; readonly truncated: boolean }
84
+ }
85
+
86
+ /** Cursor handling over the agent journal: decode (tenant-bound), encode and the latest position. */
87
+ export interface CheckpointCursors {
88
+ decode(userID: string, cursor: string): string | undefined
89
+ encode(userID: string, position: string): string
90
+ latest(userID: string): Promise<string>
91
+ }
92
+
93
+ export function journalCursors(keys: CursorKeys, events: Pick<AgentEvents, "latest">): CheckpointCursors {
94
+ return {
95
+ decode: (userID, cursor) => decodeCursor(keys, userID, cursor),
96
+ encode: (userID, position) => encodeCursor(keys, userID, position),
97
+ latest: (userID) => events.latest(userID),
98
+ }
99
+ }
100
+
101
+ export interface CheckpointStoreDeps {
102
+ readonly state: StateStore
103
+ readonly workspaces: Pick<WorkspaceStore, "require">
104
+ readonly members: Pick<MembershipStore, "members" | "assignments">
105
+ readonly supervision: Pick<SupervisionStore, "graph">
106
+ readonly cursors: CheckpointCursors
107
+ readonly now?: Clock
108
+ readonly newID?: (kind: IdKind) => string
109
+ readonly limits?: Partial<CheckpointLimits>
110
+ /** Called after a durable acknowledgment (coordinator.ts resolves the batch it names). Failures are reported. */
111
+ readonly onAck?: (userID: string, workspaceID: string, supervisorMemberID: string, batchID: string | undefined) => Promise<void>
112
+ }
113
+
114
+ interface CheckpointDoc {
115
+ readonly v: 1
116
+ readonly workspaceID: string
117
+ readonly supervisorMemberID: string
118
+ /** Oldest first. */
119
+ readonly items: readonly StoredCheckpoint[]
120
+ }
121
+
122
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
123
+ export const checkpointKey = (workspaceID: string, memberID: string) => `${workspaceID}.${memberID}`
124
+
125
+ /** Links as the scope walk needs them (live: both ends current). */
126
+ export interface ScopeLink {
127
+ readonly memberID: string
128
+ readonly supervisorMemberID: string
129
+ }
130
+
131
+ /**
132
+ * The members a supervisor is responsible for: the master supervises every other current member; anyone else its
133
+ * subtree of live supervision links. undefined when the member supervises nobody and is not the master.
134
+ */
135
+ export function supervisedMembers(workspace: Pick<HubWorkspace, "masterMemberID">, current: readonly WorkspaceMember[], links: readonly ScopeLink[], supervisorMemberID: string): Set<string> | undefined {
136
+ const ids = new Set(current.map((m) => m.id))
137
+ if (!ids.has(supervisorMemberID)) return undefined
138
+ if (workspace.masterMemberID === supervisorMemberID) return new Set([...ids].filter((id) => id !== supervisorMemberID))
139
+ const children = new Map<string, string[]>()
140
+ for (const l of links) if (ids.has(l.memberID) && ids.has(l.supervisorMemberID)) children.set(l.supervisorMemberID, [...(children.get(l.supervisorMemberID) ?? []), l.memberID])
141
+ if (!children.has(supervisorMemberID)) return undefined
142
+ const out = new Set<string>()
143
+ const queue = [supervisorMemberID]
144
+ while (queue.length) for (const child of children.get(queue.shift()!) ?? []) if (child !== supervisorMemberID && !out.has(child)) out.add(child), queue.push(child)
145
+ return out
146
+ }
147
+
148
+ function readDoc(raw: unknown, workspaceID: string, memberID: string): CheckpointDoc {
149
+ if (raw === undefined) return { v: 1, workspaceID, supervisorMemberID: memberID, items: [] }
150
+ if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
151
+ throw new AgentError("unsupported", "These checkpoints were written by a newer hub", { hint: "Upgrade this hub", extra: { feature: WORKSPACE_CHECKPOINTS } })
152
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || !Array.isArray(raw.items)) throw new AgentError("internal", "Stored checkpoints are malformed")
153
+ return raw as unknown as CheckpointDoc
154
+ }
155
+
156
+ /** -1, 0, 1 for comparable positions (same epoch); undefined across epochs. */
157
+ function compare(a: string, b: string): number | undefined {
158
+ const x = parsePosition(a)
159
+ const y = parsePosition(b)
160
+ if (!x || !y || x.epoch !== y.epoch) return undefined
161
+ return Math.sign(x.n - y.n)
162
+ }
163
+
164
+ export class CheckpointStore {
165
+ readonly limits: CheckpointLimits
166
+ private readonly now: Clock
167
+ private readonly newID: (kind: IdKind) => string
168
+
169
+ constructor(private readonly deps: CheckpointStoreDeps) {
170
+ this.limits = { perSupervisor: WORKSPACE_LIMITS.checkpointsPerSupervisor, ...deps.limits }
171
+ this.now = deps.now ?? Date.now
172
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
173
+ }
174
+
175
+ /** Records an acknowledgment and makes it durable before resolving. `created: false` for a repeat. */
176
+ async ack(userID: string, workspaceID: string, input: unknown): Promise<{ checkpoint: StoredCheckpoint; created: boolean }> {
177
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
178
+ for (const key of Object.keys(input)) if (!["cursor", "supervisorMemberID", "batchID"].includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
179
+ const { cursor, batchID } = input
180
+ if (typeof cursor !== "string" || cursor === "") throw new AgentError("invalid", "cursor is required: the agent-events cursor you processed up to")
181
+ if (input.supervisorMemberID !== undefined && !isContractID("member", input.supervisorMemberID)) throw new AgentError("invalid", "supervisorMemberID must be a workspace member ID")
182
+ if (batchID !== undefined && (typeof batchID !== "string" || !BATCH_ID.test(batchID))) throw new AgentError("invalid", "batchID must be a digest batch ID (dig_…)")
183
+ const position = this.deps.cursors.decode(userID, cursor)
184
+ if (position === undefined) throw new AgentError("invalid_cursor", "That cursor is not one this hub gave you")
185
+ if (compare(position, await this.deps.cursors.latest(userID)) === 1) throw new AgentError("invalid_cursor", "That cursor is beyond the journal")
186
+
187
+ const workspace = await this.deps.workspaces.require(userID, workspaceID)
188
+ if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
189
+ const memberID = (input.supervisorMemberID as string | undefined) ?? workspace.masterMemberID
190
+ if (memberID === undefined) throw new AgentError("invalid", "The workspace has no master: name the supervisorMemberID")
191
+ const views = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
192
+ const supervisor = views.find((v) => v.member.id === memberID)?.member
193
+ if (!supervisor) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
194
+ if (supervisor.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`)
195
+ const graph = await this.deps.supervision.graph(userID, workspaceID)
196
+ const live = graph.links.filter((l) => l.member === "current" && l.supervisor === "current").map((l) => l.link)
197
+ const scope = supervisedMembers(workspace, views.filter((v) => v.current).map((v) => v.member), live, memberID)
198
+ if (!scope) throw new AgentError("invalid", `Member ${memberID} supervises nobody`, { hint: "Checkpoints belong to the master or a member with supervised members" })
199
+ const unresolved = await this.unresolved(userID, workspaceID, scope)
200
+
201
+ const key = checkpointKey(workspaceID, memberID)
202
+ let answer: { checkpoint: StoredCheckpoint; created: boolean } | undefined
203
+ for (let attempt = 0; attempt < ATTEMPTS && !answer; attempt++) {
204
+ const stored = await this.deps.state.get(userID, WORKSPACE_CHECKPOINTS, key)
205
+ const doc = readDoc(stored?.value, workspaceID, memberID)
206
+ const last = doc.items.at(-1)
207
+ if (last) {
208
+ const order = compare(position, last.position)
209
+ if (order === -1)
210
+ throw new AgentError("conflict", "A later checkpoint is already acknowledged", { hint: "Acknowledge a newer cursor", extra: { currentCursor: last.cursor } })
211
+ if (order === 0 && last.batchID === batchID) {
212
+ answer = { checkpoint: last, created: false }
213
+ break
214
+ }
215
+ }
216
+ const checkpoint: StoredCheckpoint = {
217
+ v: 1,
218
+ id: this.newID("checkpoint"),
219
+ workspaceID,
220
+ supervisorMemberID: memberID,
221
+ cursor,
222
+ at: this.now(),
223
+ position,
224
+ sessionID: supervisor.sessionID,
225
+ generation: supervisor.generation,
226
+ ...(batchID !== undefined ? { batchID: batchID as string } : {}),
227
+ snapshot: { workspaceRevision: workspace.revision, supervisionRevision: graph.revision },
228
+ unresolved,
229
+ }
230
+ const next: CheckpointDoc = { v: 1, workspaceID, supervisorMemberID: memberID, items: [...doc.items, checkpoint].slice(-this.limits.perSupervisor) }
231
+ if ((await this.deps.state.put(userID, WORKSPACE_CHECKPOINTS, key, next, { ifVersion: stored?.version ?? 0 })).ok) answer = { checkpoint, created: true }
232
+ }
233
+ if (!answer) throw new Error(`state update of ${WORKSPACE_CHECKPOINTS}/${key} kept conflicting`)
234
+ // Durable before the answer: the standalone store debounces writes.
235
+ await this.deps.state.flush?.()
236
+ if (this.deps.onAck)
237
+ await this.deps.onAck(userID, workspaceID, memberID, answer.checkpoint.batchID).catch((cause) => console.error(`[hub] checkpoint batch resolve failed: ${(cause as Error).message}`))
238
+ return answer
239
+ }
240
+
241
+ /** A supervisor's retained checkpoints, newest first; without one, the latest of each supervisor. */
242
+ async list(userID: string, workspaceID: string, supervisorMemberID?: string): Promise<StoredCheckpoint[]> {
243
+ await this.deps.workspaces.require(userID, workspaceID)
244
+ if (supervisorMemberID !== undefined) {
245
+ if (!isContractID("member", supervisorMemberID)) throw new AgentError("invalid", "supervisorMemberID must be a workspace member ID")
246
+ const doc = readDoc((await this.deps.state.get(userID, WORKSPACE_CHECKPOINTS, checkpointKey(workspaceID, supervisorMemberID)))?.value, workspaceID, supervisorMemberID)
247
+ return [...doc.items].reverse()
248
+ }
249
+ const out: StoredCheckpoint[] = []
250
+ for (const { key, value } of await this.deps.state.list(userID, WORKSPACE_CHECKPOINTS)) {
251
+ if (!key.startsWith(`${workspaceID}.`)) continue
252
+ const last = readDoc(value, workspaceID, key.slice(workspaceID.length + 1)).items.at(-1)
253
+ if (last) out.push(last)
254
+ }
255
+ return out.sort((a, b) => b.at - a.at)
256
+ }
257
+
258
+ /** The supervisor's latest checkpoint (no workspace check: internal callers already read it). */
259
+ async latest(userID: string, workspaceID: string, supervisorMemberID: string): Promise<StoredCheckpoint | undefined> {
260
+ const raw = (await this.deps.state.get(userID, WORKSPACE_CHECKPOINTS, checkpointKey(workspaceID, supervisorMemberID)))?.value
261
+ return readDoc(raw, workspaceID, supervisorMemberID).items.at(-1)
262
+ }
263
+
264
+ private async unresolved(userID: string, workspaceID: string, scope: ReadonlySet<string>): Promise<StoredCheckpoint["unresolved"]> {
265
+ const ids: string[] = []
266
+ let count = 0
267
+ let cursor: string | undefined
268
+ do {
269
+ const page = await this.deps.members.assignments(userID, workspaceID, { limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
270
+ for (const { assignment } of page.items) {
271
+ if (!OPEN_STATUSES.has(assignment.status) || !scope.has(assignment.memberID)) continue
272
+ count++
273
+ if (ids.length < UNRESOLVED_IDS_MAX) ids.push(assignment.id)
274
+ }
275
+ cursor = page.cursor
276
+ } while (cursor)
277
+ return { count, assignmentIDs: ids, truncated: count > ids.length }
278
+ }
279
+ }
280
+
281
+ /** The public form: without the internal journal position. */
282
+ const publicCheckpoint = ({ position: _position, ...rest }: StoredCheckpoint) => rest
283
+
284
+ async function bodyOf(request: Request): Promise<unknown> {
285
+ const text = await boundedText(request, AGENT_JSON_BODY_MAX)
286
+ try {
287
+ return JSON.parse(text === "" ? "{}" : text) as unknown
288
+ } catch {
289
+ throw new AgentError("invalid", "Body must be JSON")
290
+ }
291
+ }
292
+
293
+ export function checkpointRoutes(deps: { readonly checkpoints: CheckpointStore; readonly audit?: { audit(entry: { actor: string; action: string; target: string; detail?: string }): unknown } }): AgentRoute[] {
294
+ return [
295
+ {
296
+ method: "POST",
297
+ path: `${PREFIX}/:id/checkpoints`,
298
+ allow: { scope: MANAGE_SCOPE },
299
+ async handle(context) {
300
+ const { checkpoint, created } = await deps.checkpoints.ack(context.user.id, context.params.id!, await bodyOf(context.request))
301
+ if (created) await deps.audit?.audit({ actor: context.user.id, action: "workspace.checkpoint", target: checkpoint.workspaceID, detail: context.actor.kind })
302
+ return Response.json({ checkpoint: publicCheckpoint(checkpoint) }, { status: created ? 201 : 200, headers: NO_STORE })
303
+ },
304
+ },
305
+ {
306
+ method: "GET",
307
+ path: `${PREFIX}/:id/checkpoints`,
308
+ allow: { scope: READ_SCOPE },
309
+ async handle(context) {
310
+ const member = context.url.searchParams.get("supervisorMemberID") || undefined
311
+ const items = await deps.checkpoints.list(context.user.id, context.params.id!, member)
312
+ return Response.json({ checkpoints: items.map(publicCheckpoint) }, { headers: NO_STORE })
313
+ },
314
+ },
315
+ ]
316
+ }