@huynle/hub 0.7.4 → 0.8.1

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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,513 @@
1
+ // BlobStore in Postgres (cluster mode; HWA-22, decision tctkrik8): every pod reads and writes the same artifact
2
+ // bytes, the contract in ../artifacts/blob-store.ts (the shared suite in test/blob-store-conformance.ts). It uses
3
+ // the cluster's Postgres and migration framework (migration `artifact-blobs` in ./postgres-migrate): no new
4
+ // service, and the database's backup covers the bytes.
5
+ //
6
+ // Tables (keyed by user_id first: blobs are per user, the tenant key; every statement names the user):
7
+ // artifact_blobs (user_id, hash) → bytes, chunks, stored_at, touched_at one row per visible blob
8
+ // artifact_blob_chunks (user_id, hash, chunk) → data bytea its bytes, ≤ chunkBytes each,
9
+ // deleted with the blob (cascade)
10
+ // artifact_blob_uploads (upload_id, chunk) → user_id, data, created_at uploads in flight (staging)
11
+ //
12
+ // Writes: the body streams through readBlobBody (hashed and bounded as it comes) and is cut into chunks of
13
+ // chunkBytes (default ARTIFACT_LIMITS.chunkBytes). Full chunks are staged in artifact_blob_uploads as they come,
14
+ // so a pod holds at most one chunk of a body in memory and no connection while it reads a slow client; a body
15
+ // under one chunk never touches the staging table. The commit is one transaction under a per-user advisory lock:
16
+ // refresh an existing blob (the same bytes again: nothing new, never refused for quota), or check the quota,
17
+ // insert the blob row, adopt the staged chunks, insert the last one and drop the staging rows. Readers never see
18
+ // a partial blob: the row and all its chunks appear at COMMIT. The per-user lock makes one user's concurrent puts
19
+ // (on any pod) check the quota one at a time, so they never overshoot it, and makes concurrent puts of the same
20
+ // bytes one blob.
21
+ //
22
+ // Failures: a put that fails (too large, hash mismatch, quota, a database error) drops its staging rows; a pod
23
+ // that dies mid-upload leaves rows the sweep removes (uploads whose newest chunk is older than uploadTtlMs; on put
24
+ // at most once per SWEEP_INTERVAL_MS per pod, or sweepUploads()). A commit that finds its staged chunks gone rolls
25
+ // back and fails the put (retry it). delete is one statement checking touchedBefore (atomic with the removal).
26
+ // Reads fetch one chunk per query (no connection is held between them) and verify the length and sha256 of what
27
+ // they stream: a blob deleted or changed mid-read fails the stream instead of ending it short.
28
+ //
29
+ // Retention: ArtifactStore.recover / purgeArchived (../artifacts/maintenance.ts, on a leader) decide what is
30
+ // unreferenced and call delete with touchedBefore, as for the other stores; this store only sweeps its staging.
31
+ //
32
+ // The client connects on first use (the hub builds its backends synchronously), with its own small pool so blob
33
+ // transfers never starve state queries, and first checks the schema (it never migrates unless told to, like
34
+ // PostgresStateStore). The URL holds credentials: it stays in a private field and is never put in a message.
35
+ //
36
+ // The SQL sits behind BlobRows, so the store's logic (chunking, staging, failure cleanup, verified reads) runs in
37
+ // unit tests against in-memory rows; PostgresBlobRows is covered by the HUB_IT_CLUSTER=1 suite.
38
+ import { randomUUID } from "node:crypto"
39
+ import { SQL } from "bun"
40
+ import { ARTIFACT_LIMITS, isBlobHash, type BlobRef, type Page } from "../../protocol"
41
+ import {
42
+ type BlobBody,
43
+ type BlobDeleteOptions,
44
+ type BlobInfo,
45
+ BlobInputError,
46
+ type BlobListOptions,
47
+ type BlobPutOptions,
48
+ BlobQuotaError,
49
+ type BlobRead,
50
+ type BlobStore,
51
+ type BlobUsage,
52
+ } from "../artifacts/blob-store"
53
+ import { listCursor, listWindow, putLimits, readBlobBody } from "../artifacts/blob-support"
54
+ import { migrate, SCHEMA_VERSION, schemaVersion } from "./postgres-migrate"
55
+ import { SchemaOutdatedError, statementTimeout, STATEMENT_TIMEOUT_MS } from "./postgres-state"
56
+ import type { Clock } from "./types"
57
+
58
+ /** Smallest chunk size accepted (tests use small chunks; production keeps the default). */
59
+ export const MIN_CHUNK_BYTES = 1024
60
+ /** Default idle time after which an upload's staging rows are swept (by its newest chunk). */
61
+ export const UPLOAD_TTL_MS = 60 * 60_000
62
+ /** Least time between two sweeps on put, per store. */
63
+ export const SWEEP_INTERVAL_MS = 60_000
64
+ /** Default pool size (blob transfers only). */
65
+ export const BLOB_POOL_MAX = 4
66
+ /** pg_advisory_xact_lock(namespace, hashtext(user)) namespace of blob commits ("hblb"). */
67
+ export const BLOB_LOCK_NAMESPACE = 0x6862_6c62
68
+
69
+ /** A visible blob's row. */
70
+ export interface BlobRow extends BlobInfo {
71
+ readonly chunks: number
72
+ }
73
+
74
+ /** A commit: the blob, the staged chunks to adopt and the last chunk (when there is one). */
75
+ export interface BlobCommit {
76
+ readonly userID: string
77
+ readonly hash: string
78
+ readonly bytes: number
79
+ readonly chunks: number
80
+ readonly uploadID: string
81
+ /** Chunks 0…staged-1 are staged under uploadID. */
82
+ readonly staged: number
83
+ /** The chunk after the staged ones, inserted directly. */
84
+ readonly tail?: { readonly chunk: number; readonly data: Uint8Array }
85
+ readonly at: number
86
+ readonly quotaBytes: number
87
+ }
88
+
89
+ export type CommitResult = { readonly kind: "stored" } | { readonly kind: "touched" } | { readonly kind: "quota"; readonly usedBytes: number }
90
+
91
+ /** The SQL the store runs (PostgresBlobRows), behind a seam for unit tests. Every method names the user. */
92
+ export interface BlobRows {
93
+ stage(uploadID: string, userID: string, chunk: number, data: Uint8Array, at: number): Promise<void>
94
+ /** Atomically: touch an existing blob, or check the quota and make the new blob visible (see the header). */
95
+ commit(commit: BlobCommit): Promise<CommitResult>
96
+ /** Drops an upload's staging rows. */
97
+ discard(uploadID: string, userID: string): Promise<void>
98
+ /** Drops the staging rows of uploads whose newest chunk is older than `before`; answers how many uploads. */
99
+ sweep(before: number): Promise<number>
100
+ row(userID: string, hash: string): Promise<BlobRow | undefined>
101
+ chunk(userID: string, hash: string, chunk: number): Promise<Uint8Array | undefined>
102
+ touch(userID: string, hash: string, at: number): Promise<boolean>
103
+ delete(userID: string, hash: string, touchedBefore?: number): Promise<boolean>
104
+ usage(userID: string): Promise<BlobUsage>
105
+ /** Up to `limit` rows with hash > after, by hash. */
106
+ list(userID: string, after: string | undefined, limit: number): Promise<BlobRow[]>
107
+ close(): Promise<void>
108
+ }
109
+
110
+ export interface PostgresBlobStoreOptions {
111
+ /** postgres:// or postgresql:// (with credentials). Exactly one of url and rows. */
112
+ readonly url?: string
113
+ /** Rows to use instead of a database (tests). */
114
+ readonly rows?: BlobRows
115
+ readonly now?: Clock
116
+ /** Chunk size, MIN_CHUNK_BYTES–ARTIFACT_LIMITS.chunkBytes (default ARTIFACT_LIMITS.chunkBytes). */
117
+ readonly chunkBytes?: number
118
+ /** Idle time after which an abandoned upload's staging rows are swept (default UPLOAD_TTL_MS, at least a minute). */
119
+ readonly uploadTtlMs?: number
120
+ /** Apply pending migrations on first use (default false: `hub-server migrate` does). */
121
+ readonly migrate?: boolean
122
+ /** Pool size, 1–100 (default BLOB_POOL_MAX). */
123
+ readonly max?: number
124
+ /** Seconds to wait for a connection (default Bun's). */
125
+ readonly connectionTimeoutSeconds?: number
126
+ /** Postgres statement_timeout of every connection, ms (default STATEMENT_TIMEOUT_MS; 0 turns it off). */
127
+ readonly statementTimeoutMs?: number
128
+ }
129
+
130
+ /** Checks the options; the messages never name the url. */
131
+ export function postgresBlobOptions(options: PostgresBlobStoreOptions): { chunkBytes: number; uploadTtlMs: number; max: number; statementTimeoutMs: number } {
132
+ if ((options.url === undefined) === (options.rows === undefined)) throw new Error("PostgresBlobStore needs exactly one of url and rows")
133
+ if (options.url !== undefined && !/^postgres(ql)?:\/\//.test(options.url)) throw new Error("PostgresBlobStore url must be a postgres:// or postgresql:// URL")
134
+ const chunkBytes = options.chunkBytes ?? ARTIFACT_LIMITS.chunkBytes
135
+ if (!Number.isSafeInteger(chunkBytes) || chunkBytes < MIN_CHUNK_BYTES || chunkBytes > ARTIFACT_LIMITS.chunkBytes) {
136
+ throw new Error(`PostgresBlobStore chunkBytes must be ${MIN_CHUNK_BYTES}–${ARTIFACT_LIMITS.chunkBytes}`)
137
+ }
138
+ const uploadTtlMs = options.uploadTtlMs ?? UPLOAD_TTL_MS
139
+ if (!Number.isSafeInteger(uploadTtlMs) || uploadTtlMs < 60_000) throw new Error("PostgresBlobStore uploadTtlMs must be at least 60000")
140
+ const max = options.max ?? BLOB_POOL_MAX
141
+ if (!Number.isSafeInteger(max) || max < 1 || max > 100) throw new Error("PostgresBlobStore max must be 1–100")
142
+ if (options.connectionTimeoutSeconds !== undefined && !(options.connectionTimeoutSeconds > 0)) {
143
+ throw new Error("PostgresBlobStore connectionTimeoutSeconds must be positive")
144
+ }
145
+ const statementTimeoutMs = options.statementTimeoutMs ?? STATEMENT_TIMEOUT_MS
146
+ statementTimeout("PostgresBlobStore", statementTimeoutMs)
147
+ return { chunkBytes, uploadTtlMs, max, statementTimeoutMs }
148
+ }
149
+
150
+ export class PostgresBlobStore implements BlobStore {
151
+ readonly #rows: BlobRows
152
+ readonly #now: Clock
153
+ readonly #chunkBytes: number
154
+ readonly #uploadTtlMs: number
155
+ #lastSweep = Number.NEGATIVE_INFINITY
156
+
157
+ constructor(options: PostgresBlobStoreOptions) {
158
+ const { chunkBytes, uploadTtlMs, max, statementTimeoutMs } = postgresBlobOptions(options)
159
+ this.#now = options.now ?? Date.now
160
+ this.#chunkBytes = chunkBytes
161
+ this.#uploadTtlMs = uploadTtlMs
162
+ this.#rows =
163
+ options.rows ??
164
+ new PostgresBlobRows({
165
+ url: options.url!,
166
+ max,
167
+ statementTimeoutMs,
168
+ ...(options.migrate !== undefined ? { migrate: options.migrate } : {}),
169
+ ...(options.connectionTimeoutSeconds !== undefined ? { connectionTimeoutSeconds: options.connectionTimeoutSeconds } : {}),
170
+ })
171
+ }
172
+
173
+ async put(userID: string, body: BlobBody, mediaType: string, options: BlobPutOptions = {}): Promise<BlobRef> {
174
+ const limits = putLimits(mediaType, options)
175
+ await this.#maybeSweep()
176
+ const uploadID = randomUUID()
177
+ const chunker = new Chunker(this.#chunkBytes)
178
+ let staged = 0
179
+ try {
180
+ const { hash, bytes } = await readBlobBody(body, limits, async (chunk) => {
181
+ for (const full of chunker.push(chunk)) {
182
+ // Counted before the write: a failed write may still have landed, so the cleanup must run.
183
+ staged++
184
+ await this.#rows.stage(uploadID, userID, staged - 1, full, this.#now())
185
+ }
186
+ })
187
+ const last = chunker.rest()
188
+ const tail = last ? { chunk: staged, data: last } : undefined
189
+ const result = await this.#rows.commit({
190
+ userID,
191
+ hash,
192
+ bytes,
193
+ chunks: staged + (tail ? 1 : 0),
194
+ uploadID,
195
+ staged,
196
+ ...(tail ? { tail } : {}),
197
+ at: this.#now(),
198
+ quotaBytes: limits.quotaBytes,
199
+ })
200
+ if (result.kind === "quota") throw new BlobQuotaError(limits.quotaBytes, result.usedBytes)
201
+ return { hash, bytes, mediaType }
202
+ } catch (error) {
203
+ // A successful commit leaves no staging rows; anything else drops them here (or the sweep does later).
204
+ if (staged > 0) await this.#rows.discard(uploadID, userID).catch(() => {})
205
+ throw error
206
+ }
207
+ }
208
+
209
+ async get(userID: string, hash: string): Promise<BlobRead | undefined> {
210
+ if (!isBlobHash(hash)) return undefined
211
+ const row = await this.#rows.row(userID, hash)
212
+ return row && { ...info(row), body: this.#stream(userID, row) }
213
+ }
214
+
215
+ async stat(userID: string, hash: string): Promise<BlobInfo | undefined> {
216
+ if (!isBlobHash(hash)) return undefined
217
+ const row = await this.#rows.row(userID, hash)
218
+ return row && info(row)
219
+ }
220
+
221
+ async has(userID: string, hash: string): Promise<boolean> {
222
+ return (await this.stat(userID, hash)) !== undefined
223
+ }
224
+
225
+ async touch(userID: string, hash: string): Promise<boolean> {
226
+ return isBlobHash(hash) && this.#rows.touch(userID, hash, this.#now())
227
+ }
228
+
229
+ async delete(userID: string, hash: string, options: BlobDeleteOptions = {}): Promise<boolean> {
230
+ if (!isBlobHash(hash)) return false
231
+ if (options.touchedBefore !== undefined && !Number.isFinite(options.touchedBefore)) throw new BlobInputError("touchedBefore must be a time")
232
+ return this.#rows.delete(userID, hash, options.touchedBefore)
233
+ }
234
+
235
+ usage(userID: string): Promise<BlobUsage> {
236
+ return this.#rows.usage(userID)
237
+ }
238
+
239
+ async list(userID: string, options: BlobListOptions = {}): Promise<Page<BlobInfo>> {
240
+ const { limit, after } = listWindow(options)
241
+ const rows = await this.#rows.list(userID, after, limit + 1)
242
+ const items = rows.slice(0, limit).map(info)
243
+ return rows.length > limit ? { items, cursor: listCursor(items[items.length - 1]!.hash) } : { items }
244
+ }
245
+
246
+ /** Drops the staging rows of uploads idle for uploadTtlMs (crashed pods); answers how many uploads. */
247
+ async sweepUploads(): Promise<number> {
248
+ this.#lastSweep = this.#now()
249
+ return this.#rows.sweep(this.#lastSweep - this.#uploadTtlMs)
250
+ }
251
+
252
+ /** Closes the connection pool (a later call opens a new one). */
253
+ close(): Promise<void> {
254
+ return this.#rows.close()
255
+ }
256
+
257
+ /** Sweeps on put at most once per SWEEP_INTERVAL_MS; a failed sweep never fails the put. */
258
+ async #maybeSweep(): Promise<void> {
259
+ if (this.#now() - this.#lastSweep < SWEEP_INTERVAL_MS) return
260
+ await this.sweepUploads().catch(() => {})
261
+ }
262
+
263
+ /** The blob's chunks, one query each, verified against its length and hash. */
264
+ #stream(userID: string, row: BlobRow): ReadableStream<Uint8Array> {
265
+ const rows = this.#rows
266
+ const hasher = new Bun.CryptoHasher("sha256")
267
+ let next = 0
268
+ let read = 0
269
+ return new ReadableStream<Uint8Array>({
270
+ async pull(controller) {
271
+ try {
272
+ if (next < row.chunks) {
273
+ const data = await rows.chunk(userID, row.hash, next++)
274
+ if (!data) throw new Error("blob was removed while it was read")
275
+ read += data.byteLength
276
+ if (read > row.bytes) throw new Error("blob is larger than recorded")
277
+ hasher.update(data)
278
+ if (data.byteLength > 0) controller.enqueue(data)
279
+ return
280
+ }
281
+ if (read !== row.bytes || `sha256:${hasher.digest("hex")}` !== row.hash) throw new Error("blob bytes do not match their hash")
282
+ controller.close()
283
+ } catch (cause) {
284
+ controller.error(cause)
285
+ }
286
+ },
287
+ })
288
+ }
289
+ }
290
+
291
+ const info = (row: BlobRow): BlobInfo => ({ hash: row.hash, bytes: row.bytes, storedAt: row.storedAt, touchedAt: row.touchedAt })
292
+
293
+ /** Re-cuts a byte stream into chunks of exactly `size` (the last one shorter). */
294
+ class Chunker {
295
+ #parts: Uint8Array[] = []
296
+ #bytes = 0
297
+ constructor(private readonly size: number) {}
298
+
299
+ /** The full chunks this input completes. */
300
+ push(input: Uint8Array): Uint8Array[] {
301
+ const full: Uint8Array[] = []
302
+ let rest = input
303
+ while (rest.byteLength > 0) {
304
+ const take = Math.min(this.size - this.#bytes, rest.byteLength)
305
+ this.#parts.push(rest.slice(0, take))
306
+ this.#bytes += take
307
+ rest = rest.subarray(take)
308
+ if (this.#bytes === this.size) full.push(this.#take())
309
+ }
310
+ return full
311
+ }
312
+
313
+ /** What is left (undefined when nothing). */
314
+ rest(): Uint8Array | undefined {
315
+ return this.#bytes > 0 ? this.#take() : undefined
316
+ }
317
+
318
+ #take(): Uint8Array {
319
+ const parts = this.#parts
320
+ const bytes = this.#bytes
321
+ this.#parts = []
322
+ this.#bytes = 0
323
+ if (parts.length === 1) return parts[0]!
324
+ const out = new Uint8Array(bytes)
325
+ let offset = 0
326
+ for (const part of parts) {
327
+ out.set(part, offset)
328
+ offset += part.byteLength
329
+ }
330
+ return out
331
+ }
332
+ }
333
+
334
+ // ---- Postgres --------------------------------------------------------------------------------------------------
335
+
336
+ interface PostgresBlobRowsOptions {
337
+ readonly url: string
338
+ readonly max: number
339
+ readonly migrate?: boolean
340
+ readonly connectionTimeoutSeconds?: number
341
+ readonly statementTimeoutMs: number
342
+ }
343
+
344
+ interface SqlRow {
345
+ hash: string
346
+ bytes: number
347
+ chunks: number
348
+ stored_at: number
349
+ touched_at: number
350
+ }
351
+
352
+ const fromSql = (row: SqlRow): BlobRow => ({ hash: row.hash, bytes: row.bytes, chunks: row.chunks, storedAt: row.stored_at, touchedAt: row.touched_at })
353
+ const bytea = (data: Uint8Array): Buffer => Buffer.from(data.buffer, data.byteOffset, data.byteLength)
354
+
355
+ /** BlobRows on Postgres (covered by the HUB_IT_CLUSTER=1 suite in test/central-backend-postgres-blobs.test.ts). */
356
+ export class PostgresBlobRows implements BlobRows {
357
+ readonly #url: string
358
+ readonly #options: Omit<PostgresBlobRowsOptions, "url">
359
+ #sql?: SQL
360
+ #ready?: Promise<void>
361
+
362
+ constructor(options: PostgresBlobRowsOptions) {
363
+ const { url, ...rest } = options
364
+ this.#url = url
365
+ this.#options = rest
366
+ }
367
+
368
+ async stage(uploadID: string, userID: string, chunk: number, data: Uint8Array, at: number): Promise<void> {
369
+ const sql = await this.#client()
370
+ await sql`
371
+ INSERT INTO artifact_blob_uploads (upload_id, chunk, user_id, data, created_at)
372
+ VALUES (${uploadID}::uuid, ${chunk}, ${userID}, ${bytea(data)}, ${at})`
373
+ }
374
+
375
+ async commit(c: BlobCommit): Promise<CommitResult> {
376
+ const sql = await this.#client()
377
+ const conn = await sql.reserve()
378
+ try {
379
+ // BEGIN/COMMIT by hand, as migrate does (Bun 1.3.5's reserved-connection begin() leaves failures unhandled).
380
+ await conn.unsafe("BEGIN")
381
+ try {
382
+ await conn`SELECT pg_advisory_xact_lock(${BLOB_LOCK_NAMESPACE}::int, hashtext(${c.userID}))`
383
+ const touched = await conn`
384
+ UPDATE artifact_blobs SET touched_at = ${c.at} WHERE user_id = ${c.userID} AND hash = ${c.hash} RETURNING hash`
385
+ if (touched.length > 0) {
386
+ if (c.staged > 0) await conn`DELETE FROM artifact_blob_uploads WHERE upload_id = ${c.uploadID}::uuid AND user_id = ${c.userID}`
387
+ await conn.unsafe("COMMIT")
388
+ return { kind: "touched" }
389
+ }
390
+ const [used] = (await conn`
391
+ SELECT coalesce(sum(bytes), 0)::float8 AS bytes FROM artifact_blobs WHERE user_id = ${c.userID}`) as { bytes: number }[]
392
+ const usedBytes = used?.bytes ?? 0
393
+ if (usedBytes + c.bytes > c.quotaBytes) {
394
+ await conn.unsafe("ROLLBACK")
395
+ return { kind: "quota", usedBytes }
396
+ }
397
+ await conn`
398
+ INSERT INTO artifact_blobs (user_id, hash, bytes, chunks, stored_at, touched_at)
399
+ VALUES (${c.userID}, ${c.hash}, ${c.bytes}, ${c.chunks}, ${c.at}, ${c.at})`
400
+ if (c.staged > 0) {
401
+ const adopted = await conn`
402
+ INSERT INTO artifact_blob_chunks (user_id, hash, chunk, data)
403
+ SELECT user_id, ${c.hash}, chunk, data FROM artifact_blob_uploads
404
+ WHERE upload_id = ${c.uploadID}::uuid AND user_id = ${c.userID} AND chunk < ${c.staged}
405
+ RETURNING chunk`
406
+ if (adopted.length !== c.staged) throw new Error("the upload's staged chunks are gone (swept or interrupted); try again")
407
+ await conn`DELETE FROM artifact_blob_uploads WHERE upload_id = ${c.uploadID}::uuid AND user_id = ${c.userID}`
408
+ }
409
+ if (c.tail) {
410
+ await conn`
411
+ INSERT INTO artifact_blob_chunks (user_id, hash, chunk, data)
412
+ VALUES (${c.userID}, ${c.hash}, ${c.tail.chunk}, ${bytea(c.tail.data)})`
413
+ }
414
+ await conn.unsafe("COMMIT")
415
+ return { kind: "stored" }
416
+ } catch (error) {
417
+ await conn.unsafe("ROLLBACK").catch(() => {})
418
+ throw error
419
+ }
420
+ } finally {
421
+ conn.release()
422
+ }
423
+ }
424
+
425
+ async discard(uploadID: string, userID: string): Promise<void> {
426
+ const sql = await this.#client()
427
+ await sql`DELETE FROM artifact_blob_uploads WHERE upload_id = ${uploadID}::uuid AND user_id = ${userID}`
428
+ }
429
+
430
+ async sweep(before: number): Promise<number> {
431
+ const sql = await this.#client()
432
+ const rows = (await sql`
433
+ WITH stale AS (
434
+ SELECT upload_id FROM artifact_blob_uploads GROUP BY upload_id HAVING max(created_at) < ${before}
435
+ )
436
+ DELETE FROM artifact_blob_uploads u USING stale WHERE u.upload_id = stale.upload_id
437
+ RETURNING u.upload_id::text AS id`) as { id: string }[]
438
+ return new Set(rows.map((r) => r.id)).size
439
+ }
440
+
441
+ async row(userID: string, hash: string): Promise<BlobRow | undefined> {
442
+ const sql = await this.#client()
443
+ const [row] = (await sql`
444
+ SELECT hash, bytes::float8 AS bytes, chunks, stored_at::float8 AS stored_at, touched_at::float8 AS touched_at
445
+ FROM artifact_blobs WHERE user_id = ${userID} AND hash = ${hash}`) as SqlRow[]
446
+ return row && fromSql(row)
447
+ }
448
+
449
+ async chunk(userID: string, hash: string, chunk: number): Promise<Uint8Array | undefined> {
450
+ const sql = await this.#client()
451
+ const [row] = (await sql`
452
+ SELECT data FROM artifact_blob_chunks WHERE user_id = ${userID} AND hash = ${hash} AND chunk = ${chunk}`) as { data: Uint8Array }[]
453
+ return row && new Uint8Array(row.data.buffer, row.data.byteOffset, row.data.byteLength)
454
+ }
455
+
456
+ async touch(userID: string, hash: string, at: number): Promise<boolean> {
457
+ const sql = await this.#client()
458
+ const rows = await sql`UPDATE artifact_blobs SET touched_at = ${at} WHERE user_id = ${userID} AND hash = ${hash} RETURNING hash`
459
+ return rows.length > 0
460
+ }
461
+
462
+ async delete(userID: string, hash: string, touchedBefore?: number): Promise<boolean> {
463
+ const sql = await this.#client()
464
+ const rows =
465
+ touchedBefore === undefined
466
+ ? await sql`DELETE FROM artifact_blobs WHERE user_id = ${userID} AND hash = ${hash} RETURNING hash`
467
+ : await sql`DELETE FROM artifact_blobs WHERE user_id = ${userID} AND hash = ${hash} AND touched_at < ${touchedBefore} RETURNING hash`
468
+ return rows.length > 0
469
+ }
470
+
471
+ async usage(userID: string): Promise<BlobUsage> {
472
+ const sql = await this.#client()
473
+ const [row] = (await sql`
474
+ SELECT count(*)::int AS blobs, coalesce(sum(bytes), 0)::float8 AS bytes FROM artifact_blobs WHERE user_id = ${userID}`) as BlobUsage[]
475
+ return { blobs: row?.blobs ?? 0, bytes: row?.bytes ?? 0 }
476
+ }
477
+
478
+ async list(userID: string, after: string | undefined, limit: number): Promise<BlobRow[]> {
479
+ const sql = await this.#client()
480
+ const rows = (await sql`
481
+ SELECT hash, bytes::float8 AS bytes, chunks, stored_at::float8 AS stored_at, touched_at::float8 AS touched_at
482
+ FROM artifact_blobs WHERE user_id = ${userID} AND hash > ${after ?? ""}
483
+ ORDER BY hash LIMIT ${limit}`) as SqlRow[]
484
+ return rows.map(fromSql)
485
+ }
486
+
487
+ async close(): Promise<void> {
488
+ const sql = this.#sql
489
+ this.#sql = undefined
490
+ this.#ready = undefined
491
+ await sql?.close()
492
+ }
493
+
494
+ /** The pool, created on first use, once the schema is current; a failed check is retried by the next call. */
495
+ async #client(): Promise<SQL> {
496
+ const sql = (this.#sql ??= new SQL(this.#url, {
497
+ max: this.#options.max,
498
+ ...(this.#options.connectionTimeoutSeconds !== undefined ? { connectionTimeout: this.#options.connectionTimeoutSeconds } : {}),
499
+ ...statementTimeout("PostgresBlobStore", this.#options.statementTimeoutMs),
500
+ }))
501
+ this.#ready ??= this.#checkSchema(sql).catch((error: unknown) => {
502
+ this.#ready = undefined
503
+ throw error
504
+ })
505
+ await this.#ready
506
+ return sql
507
+ }
508
+
509
+ async #checkSchema(sql: SQL): Promise<void> {
510
+ const version = this.#options.migrate ? (await migrate(sql)).version : await schemaVersion(sql)
511
+ if (version < SCHEMA_VERSION) throw new SchemaOutdatedError(version, SCHEMA_VERSION)
512
+ }
513
+ }
@@ -35,6 +35,39 @@ export const MIGRATIONS: readonly Migration[] = [
35
35
  );
36
36
  `,
37
37
  },
38
+ {
39
+ // Artifact blobs (HWA-22, ./postgres-blobs): additive, so the previous release keeps working.
40
+ version: 2,
41
+ name: "artifact-blobs",
42
+ up: `
43
+ CREATE TABLE artifact_blobs (
44
+ user_id text COLLATE "C" NOT NULL,
45
+ hash text COLLATE "C" NOT NULL CHECK (hash ~ '^sha256:[0-9a-f]{64}$'),
46
+ bytes bigint NOT NULL CHECK (bytes >= 0),
47
+ chunks integer NOT NULL CHECK (chunks >= 0),
48
+ stored_at bigint NOT NULL,
49
+ touched_at bigint NOT NULL,
50
+ PRIMARY KEY (user_id, hash)
51
+ );
52
+ CREATE TABLE artifact_blob_chunks (
53
+ user_id text COLLATE "C" NOT NULL,
54
+ hash text COLLATE "C" NOT NULL,
55
+ chunk integer NOT NULL CHECK (chunk >= 0),
56
+ data bytea NOT NULL,
57
+ PRIMARY KEY (user_id, hash, chunk),
58
+ FOREIGN KEY (user_id, hash) REFERENCES artifact_blobs (user_id, hash) ON DELETE CASCADE
59
+ );
60
+ CREATE TABLE artifact_blob_uploads (
61
+ upload_id uuid NOT NULL,
62
+ chunk integer NOT NULL CHECK (chunk >= 0),
63
+ user_id text COLLATE "C" NOT NULL,
64
+ data bytea NOT NULL,
65
+ created_at bigint NOT NULL,
66
+ PRIMARY KEY (upload_id, chunk)
67
+ );
68
+ CREATE INDEX artifact_blob_uploads_created ON artifact_blob_uploads (created_at);
69
+ `,
70
+ },
38
71
  ]
39
72
 
40
73
  /** The schema version this code needs. */
@@ -21,6 +21,22 @@ export interface PostgresStateStoreOptions {
21
21
  readonly max?: number
22
22
  /** Seconds to wait for a connection (default Bun's). */
23
23
  readonly connectionTimeoutSeconds?: number
24
+ /** Postgres statement_timeout of every connection, ms (default STATEMENT_TIMEOUT_MS; 0 turns it off). */
25
+ readonly statementTimeoutMs?: number
26
+ }
27
+
28
+ /**
29
+ * Default statement_timeout (ms): a statement stuck on the server (a lock that is never released, an overloaded
30
+ * database) fails with an error instead of holding its caller and a pooled connection for ever. The hub's
31
+ * statements are small; migrations run under it too.
32
+ */
33
+ export const STATEMENT_TIMEOUT_MS = 30_000
34
+
35
+ /** Checks statementTimeoutMs and answers Bun.SQL's `connection` option (startup parameters) for it. */
36
+ export function statementTimeout(owner: string, ms: number | undefined): { connection?: { statement_timeout: number } } {
37
+ const value = ms ?? STATEMENT_TIMEOUT_MS
38
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${owner} statementTimeoutMs must be a non-negative integer (0 turns it off)`)
39
+ return value > 0 ? { connection: { statement_timeout: value } } : {}
24
40
  }
25
41
 
26
42
  /** The database is behind the schema this hub needs. The message names no URL. */
@@ -48,6 +64,7 @@ export class PostgresStateStore implements StateStore {
48
64
  readonly #url: string
49
65
  readonly #options: Omit<PostgresStateStoreOptions, "url">
50
66
  readonly #now: Clock
67
+ readonly #timeout: { connection?: { statement_timeout: number } }
51
68
  #sql?: SQL
52
69
  #ready?: Promise<void>
53
70
 
@@ -55,6 +72,7 @@ export class PostgresStateStore implements StateStore {
55
72
  const { url, ...rest } = options
56
73
  this.#url = url
57
74
  this.#options = rest
75
+ this.#timeout = statementTimeout("PostgresStateStore", options.statementTimeoutMs)
58
76
  this.#now = options.now ?? Date.now
59
77
  }
60
78
 
@@ -181,6 +199,7 @@ export class PostgresStateStore implements StateStore {
181
199
  const sql = (this.#sql ??= new SQL(this.#url, {
182
200
  max: this.#options.max ?? 10,
183
201
  ...(this.#options.connectionTimeoutSeconds !== undefined ? { connectionTimeout: this.#options.connectionTimeoutSeconds } : {}),
202
+ ...this.#timeout,
184
203
  }))
185
204
  this.#ready ??= this.#checkSchema(sql).catch((error: unknown) => {
186
205
  this.#ready = undefined
@@ -229,6 +229,12 @@ export interface Catalog {
229
229
  entries(userID: string): Promise<CatalogEntry[]>
230
230
  }
231
231
 
232
+ // ---- BlobStore --------------------------------------------------------------------------------------------
233
+
234
+ /** Artifact bytes (HWA-20): the contract and its rules live in ../artifacts/blob-store; memory impl ./memory-blobs. */
235
+ export type { BlobBody, BlobDeleteOptions, BlobInfo, BlobListOptions, BlobPutOptions, BlobRead, BlobStore, BlobUsage } from "../artifacts/blob-store"
236
+ import type { BlobStore } from "../artifacts/blob-store"
237
+
232
238
  // ---- Bundle -----------------------------------------------------------------------------------------------
233
239
 
234
240
  export interface Backends {
@@ -241,6 +247,8 @@ export interface Backends {
241
247
  readonly locks: Locks
242
248
  readonly catalog: Catalog
243
249
  readonly kv: Kv
250
+ /** Artifact bytes (HWA-21: files standalone, memory in tests; absent in a cluster until HWA-22 → artifacts unsupported). */
251
+ readonly blobs?: BlobStore
244
252
  /** Closes the connections a networked bundle opened (cluster mode); the hub calls it on stop. */
245
253
  close?(): Promise<void>
246
254
  }