@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,776 @@
1
+ // Persistent artifacts (HWA-20; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts):
2
+ // versioned per-user metadata and immutable revisions in the StateStore, bytes in a BlobStore. Not LaunchArtifacts
3
+ // (launch git metadata): these records have their own collections and lifecycle, so launch pruning and session or
4
+ // launch expiry never touch them.
5
+ //
6
+ // Storage, per user (the tenant key):
7
+ // - "artifacts" (ARTIFACTS): artifactID → { artifact, head, archivedAt?, deletingAt? }. `head` is the number of
8
+ // the latest revision; `artifact.revision` is the record's CAS counter (every change, including a new revision).
9
+ // - "artifact-revisions.<artifactID>": zero-padded revision number → ArtifactRevision (immutable). Only numbers up
10
+ // to `head` are visible; a higher one is an upload that has not committed (or never will).
11
+ // - "artifact-blob-refs" (BLOB_REFS): hash → { holders }. A holder is an artifact ID (a published revision of it
12
+ // uses the blob) or `<artifactID>/<revisionID>` (an upload in flight). A blob without holders is unreferenced.
13
+ // - "artifact-uploads" (UPLOADS): revisionID → the upload in flight (phase storing → publishing).
14
+ //
15
+ // Commit before publish. A new revision (create or revise) is published in this order:
16
+ // 1. record the upload; 2. store each file's bytes (the BlobStore verifies size, quota and hash) and add the
17
+ // upload's holder to its blob; 3. touch every blob (fails if one vanished); 4. claim the publishing phase (CAS on
18
+ // the upload); 5. write the revision record (create-only); 6. CAS the artifact to point at it: the only step that
19
+ // makes the revision visible; 7. turn the upload's holders into artifact holders and drop the upload record.
20
+ // Every stored file of a bundle records progress on the upload, so the grace period below is an inactivity limit.
21
+ // A failure before 6 that is known not to have committed cleans up at once. A failure whose outcome is unknown (a
22
+ // lost reply on step 6) leaves everything for recover(), which after the grace period decides from the artifact
23
+ // record whether the revision was published, and finishes or undoes it.
24
+ //
25
+ // Deleting bytes. Removing a blob's last holder is followed by BlobStore.delete(hash, {touchedBefore: readTime -
26
+ // grace}). A writer always adds its holder before touching the blob, so a writer the deleter could not see has
27
+ // touched it later than that and keeps it; bytes that were stored but never referenced (a failed upload) are
28
+ // removed by recover() once untouched for the grace period. The grace period also covers clock skew between hub
29
+ // replicas.
30
+ //
31
+ // Lifecycle: archive is reversible and keeps everything; delete is irreversible (tombstone first, so a crash half
32
+ // way is finished by recover()); archived artifacts are purged only when `archivedRetentionMs` is set (default:
33
+ // kept until deleted).
34
+ import { randomBytes } from "node:crypto"
35
+ import {
36
+ ARTIFACT_LIMITS,
37
+ ARTIFACT_MEDIA_TYPES,
38
+ artifactInputProblem,
39
+ blobRefProblem,
40
+ ID_PREFIXES,
41
+ type IdKind,
42
+ isContractID,
43
+ isExpectedRevision,
44
+ LIST_LIMIT_DEFAULT,
45
+ LIST_LIMIT_MAX,
46
+ type Artifact,
47
+ type ArtifactKind,
48
+ type ArtifactRevision,
49
+ type ArtifactVisibility,
50
+ type BlobRef,
51
+ type Page,
52
+ type RecordState,
53
+ } from "../../protocol"
54
+ import { AgentError } from "../agent/errors"
55
+ import type { Clock, StateStore } from "../backend/types"
56
+ import { type ArtifactAction, type ArtifactAuthorizer, type ArtifactPrincipal, type ArtifactWorkspaces, defaultArtifactPolicy } from "./access"
57
+ import { type BlobBody, BlobHashMismatchError, BlobInputError, BlobQuotaError, type BlobRead, type BlobStore, BlobTooLargeError } from "./blob-store"
58
+
59
+ export type { ArtifactPrincipal, ArtifactWorkspaces } from "./access"
60
+
61
+ export const ARTIFACTS = "artifacts"
62
+ export const BLOB_REFS = "artifact-blob-refs"
63
+ export const UPLOADS = "artifact-uploads"
64
+ export const revisionsCollection = (artifactID: string): string => `artifact-revisions.${artifactID}`
65
+ /** How long an upload may run, and an unreferenced blob stays, before recovery treats it as abandoned. */
66
+ export const ARTIFACT_GRACE_MS = 15 * 60_000
67
+ /** CAS attempts on the artifact record when unrelated changes race a commit. */
68
+ const ATTEMPTS = 16
69
+
70
+ export interface ArtifactLimits {
71
+ readonly artifactsPerUser: number
72
+ readonly revisionsPerArtifact: number
73
+ readonly blobBytes: number
74
+ readonly userBlobBytes: number
75
+ readonly bundleFiles: number
76
+ }
77
+
78
+ /** Compact event payload: IDs, kinds, revisions and counts only (never titles or content). */
79
+ export interface ArtifactEvent {
80
+ readonly type: "artifact.created" | "artifact.revised" | "artifact.archived" | "artifact.visibility"
81
+ readonly artifactID: string
82
+ readonly kind: ArtifactKind
83
+ readonly revision: number
84
+ readonly revisionID?: string
85
+ readonly number?: number
86
+ /** artifact.archived: the state it moved to (archive and unarchive share the event). */
87
+ readonly state?: RecordState
88
+ }
89
+
90
+ export interface ArtifactStoreOptions {
91
+ readonly state: StateStore
92
+ readonly blobs: BlobStore
93
+ readonly now?: Clock
94
+ /** Workspace facts; without them workspace visibility is `unsupported`. */
95
+ readonly workspaces?: ArtifactWorkspaces
96
+ /** Access policy (default: defaultArtifactPolicy). */
97
+ readonly authorize?: ArtifactAuthorizer
98
+ /** Tighter limits than ARTIFACT_LIMITS (never looser for blobBytes). */
99
+ readonly limits?: Partial<ArtifactLimits>
100
+ readonly graceMs?: number
101
+ /** Purge archived artifacts this long after archiving (default: never). */
102
+ readonly archivedRetentionMs?: number
103
+ readonly onEvent?: (userID: string, event: ArtifactEvent) => void
104
+ readonly newID?: (kind: IdKind) => string
105
+ }
106
+
107
+ /** A file of a new revision: new bytes, or a blob this user already stored (unchanged bundle files). */
108
+ export type RevisionFile =
109
+ | { readonly body: BlobBody; readonly mediaType: string; readonly path?: string; readonly expectedHash?: string }
110
+ | { readonly ref: BlobRef }
111
+
112
+ export interface RevisionInput {
113
+ readonly files: readonly RevisionFile[]
114
+ /** Entry file of a bundle: one of the files' paths. */
115
+ readonly entry?: string
116
+ }
117
+
118
+ export interface ArtifactInput {
119
+ readonly title: string
120
+ readonly kind: string
121
+ readonly description?: string
122
+ readonly visibility?: ArtifactVisibility
123
+ readonly workspaceID?: string
124
+ readonly tags?: readonly string[]
125
+ readonly metadata?: Readonly<Record<string, string>>
126
+ }
127
+
128
+ export interface ArtifactUpdate {
129
+ readonly expectedRevision: number
130
+ readonly title?: string
131
+ readonly description?: string
132
+ readonly tags?: readonly string[]
133
+ readonly metadata?: Readonly<Record<string, string>>
134
+ }
135
+
136
+ export interface ArtifactListOptions {
137
+ readonly limit?: number
138
+ readonly cursor?: string
139
+ /** Default "active". */
140
+ readonly state?: RecordState
141
+ readonly workspaceID?: string
142
+ }
143
+
144
+ export interface RecoveryReport {
145
+ readonly artifactsDeleted: number
146
+ readonly uploadsRecovered: number
147
+ readonly revisionsRemoved: number
148
+ readonly holdersRemoved: number
149
+ readonly blobsDeleted: number
150
+ }
151
+
152
+ interface StoredArtifact {
153
+ readonly artifact: Artifact
154
+ readonly head: number
155
+ readonly archivedAt?: number
156
+ readonly deletingAt?: number
157
+ }
158
+
159
+ interface Holder {
160
+ readonly id: string
161
+ readonly at: number
162
+ }
163
+
164
+ interface StoredRefs {
165
+ readonly holders: readonly Holder[]
166
+ }
167
+
168
+ interface Upload {
169
+ readonly artifactID: string
170
+ readonly revisionID: string
171
+ readonly number: number
172
+ readonly phase: "storing" | "publishing"
173
+ readonly startedAt: number
174
+ readonly phaseAt: number
175
+ }
176
+
177
+ type Published = { readonly artifact: Artifact; readonly revision: ArtifactRevision }
178
+
179
+ const ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
180
+ const randomID = (kind: IdKind): string => `${ID_PREFIXES[kind]}_${[...randomBytes(24)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("")}`
181
+ const revisionKey = (number: number): string => String(number).padStart(6, "0")
182
+ const pendingHolder = (artifactID: string, revisionID: string): string => `${artifactID}/${revisionID}`
183
+ /** Any placeholder hash: request checks of a file's media type and path happen before its bytes are read. */
184
+ const NO_HASH = `sha256:${"0".repeat(64)}`
185
+
186
+ const notFound = (): AgentError => new AgentError("not_found", "No such artifact")
187
+ const forbidden = (): AgentError => new AgentError("forbidden", "Not allowed on this artifact")
188
+ const invalid = (message: string): AgentError => new AgentError("invalid", message)
189
+ const conflict = (message: string, stored: StoredArtifact | undefined): AgentError =>
190
+ new AgentError("conflict", message, {
191
+ extra: { currentRevision: stored?.artifact.revision ?? 0, ...(stored ? { latestRevisionID: stored.artifact.latestRevisionID } : {}) },
192
+ })
193
+ const capReached = (what: string, limit: number): AgentError => new AgentError("rate_limited", `${what} limit of ${limit} reached`, { extra: { limit } })
194
+
195
+ const encodeCursor = (value: unknown): string => Buffer.from(JSON.stringify(value), "utf8").toString("base64url")
196
+ function decodeCursor(cursor: string): unknown {
197
+ try {
198
+ return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))
199
+ } catch {
200
+ throw invalid("cursor is not valid")
201
+ }
202
+ }
203
+ function pageLimit(limit: number | undefined): number {
204
+ const value = limit ?? LIST_LIMIT_DEFAULT
205
+ if (!Number.isSafeInteger(value) || value < 1 || value > LIST_LIMIT_MAX) throw invalid(`limit must be 1–${LIST_LIMIT_MAX}`)
206
+ return value
207
+ }
208
+
209
+ export class ArtifactStore {
210
+ private readonly state: StateStore
211
+ private readonly blobs: BlobStore
212
+ private readonly now: Clock
213
+ private readonly workspaces: ArtifactWorkspaces | undefined
214
+ private readonly authorize: ArtifactAuthorizer
215
+ private readonly limits: ArtifactLimits
216
+ private readonly graceMs: number
217
+ private readonly archivedRetentionMs: number | undefined
218
+ private readonly onEvent: ((userID: string, event: ArtifactEvent) => void) | undefined
219
+ private readonly newID: (kind: IdKind) => string
220
+
221
+ constructor(options: ArtifactStoreOptions) {
222
+ this.state = options.state
223
+ this.blobs = options.blobs
224
+ this.now = options.now ?? Date.now
225
+ this.workspaces = options.workspaces
226
+ this.authorize = options.authorize ?? defaultArtifactPolicy
227
+ this.limits = { ...ARTIFACT_LIMITS, ...options.limits }
228
+ if (this.limits.blobBytes > ARTIFACT_LIMITS.blobBytes) throw new Error("limits.blobBytes exceeds ARTIFACT_LIMITS.blobBytes")
229
+ this.graceMs = options.graceMs ?? ARTIFACT_GRACE_MS
230
+ this.archivedRetentionMs = options.archivedRetentionMs
231
+ this.onEvent = options.onEvent
232
+ this.newID = options.newID ?? randomID
233
+ }
234
+
235
+ // ---- reads ----------------------------------------------------------------------------------------------
236
+
237
+ async get(userID: string, principal: ArtifactPrincipal, id: string): Promise<Artifact> {
238
+ return (await this.load(userID, principal, id, "read")).stored.artifact
239
+ }
240
+
241
+ /** The artifacts the principal may read, most recently updated first. */
242
+ async list(userID: string, principal: ArtifactPrincipal, options: ArtifactListOptions = {}): Promise<Page<Artifact>> {
243
+ const limit = pageLimit(options.limit)
244
+ let after: { updatedAt: number; id: string } | undefined
245
+ if (options.cursor !== undefined) {
246
+ const raw = decodeCursor(options.cursor)
247
+ if (!Array.isArray(raw) || typeof raw[0] !== "number" || typeof raw[1] !== "string") throw invalid("cursor is not valid")
248
+ after = { updatedAt: raw[0], id: raw[1] }
249
+ }
250
+ const state = options.state ?? "active"
251
+ const candidates = (await this.state.list<StoredArtifact>(userID, ARTIFACTS))
252
+ .map((entry) => entry.value)
253
+ .filter((s) => s.deletingAt === undefined && s.artifact.state === state)
254
+ .filter((s) => options.workspaceID === undefined || s.artifact.workspaceID === options.workspaceID)
255
+ .map((s) => s.artifact)
256
+ .sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
257
+ .filter((a) => !after || a.updatedAt < after.updatedAt || (a.updatedAt === after.updatedAt && a.id > after.id))
258
+ const items: Artifact[] = []
259
+ let more = false
260
+ for (const artifact of candidates) {
261
+ if (!(await this.allowed(userID, principal, "read", artifact))) continue
262
+ if (items.length === limit) {
263
+ more = true
264
+ break
265
+ }
266
+ items.push(artifact)
267
+ }
268
+ const last = items[items.length - 1]
269
+ return more && last ? { items, cursor: encodeCursor([last.updatedAt, last.id]) } : { items }
270
+ }
271
+
272
+ /** The visible revisions, newest first. */
273
+ async listRevisions(userID: string, principal: ArtifactPrincipal, id: string, options: { limit?: number; cursor?: string } = {}): Promise<Page<ArtifactRevision>> {
274
+ const limit = pageLimit(options.limit)
275
+ let before = Infinity
276
+ if (options.cursor !== undefined) {
277
+ const raw = decodeCursor(options.cursor)
278
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1) throw invalid("cursor is not valid")
279
+ before = raw
280
+ }
281
+ const { stored } = await this.load(userID, principal, id, "read")
282
+ const visible = (await this.visibleRevisions(userID, stored)).filter((r) => r.number < before)
283
+ const items = visible.slice(0, limit)
284
+ const last = items[items.length - 1]
285
+ return visible.length > limit && last ? { items, cursor: encodeCursor(last.number) } : { items }
286
+ }
287
+
288
+ async getRevision(userID: string, principal: ArtifactPrincipal, id: string, revisionID: string): Promise<ArtifactRevision> {
289
+ const { stored } = await this.load(userID, principal, id, "read")
290
+ if (!isContractID("revision", revisionID)) throw new AgentError("not_found", "No such revision")
291
+ const revision = (await this.visibleRevisions(userID, stored)).find((r) => r.id === revisionID)
292
+ if (!revision) throw new AgentError("not_found", "No such revision")
293
+ return revision
294
+ }
295
+
296
+ /** One blob of a visible revision, checked against that revision's references. */
297
+ async readBlob(userID: string, principal: ArtifactPrincipal, id: string, revisionID: string, hash: string): Promise<{ ref: BlobRef; blob: BlobRead }> {
298
+ const revision = await this.getRevision(userID, principal, id, revisionID)
299
+ const ref = revision.blobs.find((b) => b.hash === hash)
300
+ if (!ref) throw new AgentError("not_found", "No such blob in this revision")
301
+ const blob = await this.blobs.get(userID, hash)
302
+ // Bytes are stored before a revision is published and kept while it exists: a miss is a broken store.
303
+ if (!blob) throw new AgentError("internal", "The artifact's bytes are missing")
304
+ return { ref, blob }
305
+ }
306
+
307
+ // ---- revisions ------------------------------------------------------------------------------------------
308
+
309
+ /** Creates an artifact with its first revision; the artifact appears only after the revision's bytes are stored. */
310
+ async create(userID: string, principal: ArtifactPrincipal, input: ArtifactInput, revision: RevisionInput): Promise<Published> {
311
+ const problem = artifactInputProblem(input)
312
+ if (problem) throw invalid(problem)
313
+ if (input.workspaceID !== undefined) await this.checkWorkspace(userID, input.workspaceID)
314
+ if (!(await this.allowed(userID, principal, "create"))) throw forbidden()
315
+ this.checkRevisionInput(revision)
316
+ const live = (await this.state.list<StoredArtifact>(userID, ARTIFACTS)).filter((e) => e.value.deletingAt === undefined)
317
+ if (live.length >= this.limits.artifactsPerUser) throw capReached("Artifact", this.limits.artifactsPerUser)
318
+ const artifactID = this.newID("artifact")
319
+ const published = await this.publish(userID, artifactID, 1, undefined, revision, authorOf(principal), async (rev) => {
320
+ const at = rev.createdAt
321
+ const artifact: Artifact = {
322
+ v: 1,
323
+ id: artifactID,
324
+ revision: 1,
325
+ title: input.title,
326
+ ...(input.description !== undefined ? { description: input.description } : {}),
327
+ kind: input.kind as ArtifactKind,
328
+ state: "active",
329
+ visibility: input.visibility ?? "private",
330
+ ...(input.workspaceID !== undefined ? { workspaceID: input.workspaceID } : {}),
331
+ ...(principal.kind === "session" ? { createdBySessionID: principal.sessionID } : {}),
332
+ latestRevisionID: rev.id,
333
+ ...(input.tags !== undefined ? { tags: [...input.tags] } : {}),
334
+ ...(input.metadata !== undefined ? { metadata: { ...input.metadata } } : {}),
335
+ createdAt: at,
336
+ updatedAt: at,
337
+ }
338
+ const written = await this.state.put<StoredArtifact>(userID, ARTIFACTS, artifactID, { artifact, head: 1 }, { ifVersion: 0 })
339
+ if (!written.ok) throw conflict("Artifact ID collision; retry", undefined)
340
+ return artifact
341
+ })
342
+ this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1 })
343
+ return published
344
+ }
345
+
346
+ /** Adds a revision on `baseRevisionID`, which must be the latest (else 409 conflict; there is no silent merge). */
347
+ async revise(
348
+ userID: string,
349
+ principal: ArtifactPrincipal,
350
+ id: string,
351
+ input: RevisionInput & { readonly baseRevisionID: string },
352
+ options: { readonly aiJobID?: string } = {},
353
+ ): Promise<Published> {
354
+ const { stored } = await this.load(userID, principal, id, "revise")
355
+ if (stored.artifact.state === "archived") throw invalid("The artifact is archived; unarchive it first")
356
+ const base = input.baseRevisionID
357
+ if (stored.artifact.latestRevisionID !== base) throw conflict("baseRevisionID is not the latest revision", stored)
358
+ const number = stored.head + 1
359
+ if (number > this.limits.revisionsPerArtifact) throw capReached("Revision", this.limits.revisionsPerArtifact)
360
+ if (options.aiJobID !== undefined && !isContractID("aiJob", options.aiJobID)) throw invalid("aiJobID must be a job ID")
361
+ this.checkRevisionInput(input)
362
+ const author: ArtifactRevision["author"] = options.aiJobID !== undefined ? { kind: "ai_job", jobID: options.aiJobID } : authorOf(principal)
363
+ const published = await this.publish(userID, id, number, base, input, author, async (rev) => {
364
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
365
+ const current = await this.state.get<StoredArtifact>(userID, ARTIFACTS, id)
366
+ if (!current || current.value.deletingAt !== undefined) throw notFound()
367
+ if (current.value.artifact.state === "archived") throw invalid("The artifact is archived; unarchive it first")
368
+ if (current.value.artifact.latestRevisionID !== base) throw conflict("baseRevisionID is not the latest revision", current.value)
369
+ const artifact: Artifact = { ...current.value.artifact, latestRevisionID: rev.id, revision: current.value.artifact.revision + 1, updatedAt: rev.createdAt }
370
+ const written = await this.state.put<StoredArtifact>(userID, ARTIFACTS, id, { ...current.value, artifact, head: rev.number }, { ifVersion: current.version })
371
+ if (written.ok) return artifact
372
+ }
373
+ throw conflict("The artifact kept changing; retry", undefined)
374
+ })
375
+ this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number })
376
+ return published
377
+ }
378
+
379
+ // ---- metadata and lifecycle -----------------------------------------------------------------------------
380
+
381
+ async update(userID: string, principal: ArtifactPrincipal, id: string, input: ArtifactUpdate): Promise<Artifact> {
382
+ return this.mutate(userID, principal, id, input.expectedRevision, async (s) => {
383
+ const a = s.artifact
384
+ const next = {
385
+ title: input.title ?? a.title,
386
+ description: input.description ?? a.description,
387
+ kind: a.kind,
388
+ visibility: a.visibility,
389
+ workspaceID: a.workspaceID,
390
+ tags: input.tags ?? a.tags,
391
+ metadata: input.metadata ?? a.metadata,
392
+ }
393
+ const problem = artifactInputProblem(dropUndefined(next))
394
+ if (problem) throw invalid(problem)
395
+ return { ...s, artifact: dropUndefined({ ...a, ...next, tags: next.tags && [...next.tags], metadata: next.metadata && { ...next.metadata } }) as Artifact }
396
+ })
397
+ }
398
+
399
+ async setVisibility(
400
+ userID: string,
401
+ principal: ArtifactPrincipal,
402
+ id: string,
403
+ input: { readonly expectedRevision: number; readonly visibility: ArtifactVisibility; readonly workspaceID?: string },
404
+ ): Promise<Artifact> {
405
+ const artifact = await this.mutate(userID, principal, id, input.expectedRevision, async (s) => {
406
+ const workspaceID = input.workspaceID ?? s.artifact.workspaceID
407
+ const problem = artifactInputProblem(dropUndefined({ title: s.artifact.title, kind: s.artifact.kind, visibility: input.visibility, workspaceID }))
408
+ if (problem) throw invalid(problem)
409
+ // Making an artifact private never needs the workspace directory; sharing or moving it does.
410
+ if (workspaceID !== undefined && (input.visibility === "workspace" || workspaceID !== s.artifact.workspaceID)) await this.checkWorkspace(userID, workspaceID)
411
+ return { ...s, artifact: dropUndefined({ ...s.artifact, visibility: input.visibility, workspaceID }) as Artifact }
412
+ })
413
+ this.emit(userID, { type: "artifact.visibility", artifactID: id, kind: artifact.kind, revision: artifact.revision })
414
+ return artifact
415
+ }
416
+
417
+ async archive(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<Artifact> {
418
+ return this.setState(userID, principal, id, expectedRevision, "archived")
419
+ }
420
+
421
+ async unarchive(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<Artifact> {
422
+ return this.setState(userID, principal, id, expectedRevision, "active")
423
+ }
424
+
425
+ /** Irreversible: removes the artifact, its revisions and its blob references (unreferenced bytes go too). */
426
+ async delete(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<void> {
427
+ await this.mutate(userID, principal, id, expectedRevision, async (s) => ({ ...s, deletingAt: this.now() }))
428
+ await this.finishDelete(userID, id)
429
+ }
430
+
431
+ /** Purges archived artifacts older than archivedRetentionMs (none when unset); resolves with their IDs. */
432
+ async purgeArchived(userID: string): Promise<string[]> {
433
+ if (this.archivedRetentionMs === undefined) return []
434
+ const cutoff = this.now() - this.archivedRetentionMs
435
+ const purged: string[] = []
436
+ for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
437
+ const s = entry.value
438
+ if (s.deletingAt !== undefined || s.artifact.state !== "archived" || s.archivedAt === undefined || s.archivedAt > cutoff) continue
439
+ const marked = await this.state.put<StoredArtifact>(userID, ARTIFACTS, entry.key, { ...s, deletingAt: this.now() }, { ifVersion: entry.version })
440
+ if (!marked.ok) continue
441
+ await this.finishDelete(userID, entry.key)
442
+ purged.push(entry.key)
443
+ }
444
+ return purged
445
+ }
446
+
447
+ // ---- recovery -------------------------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Settles what crashed or lost writers left behind, for one user: finishes deletes, decides stale uploads
451
+ * (published: keep and finalize; not: remove the record and holders), drops dangling holders, and removes
452
+ * blobs without holders that nobody touched for the grace period. Safe to run any time and concurrently.
453
+ */
454
+ async recover(userID: string): Promise<RecoveryReport> {
455
+ let artifactsDeleted = 0
456
+ let uploadsRecovered = 0
457
+ let revisionsRemoved = 0
458
+ let holdersRemoved = 0
459
+ let blobsDeleted = 0
460
+ for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
461
+ if (entry.value.deletingAt === undefined) continue
462
+ await this.finishDelete(userID, entry.key)
463
+ artifactsDeleted++
464
+ }
465
+ const stale = this.now() - this.graceMs
466
+ for (const entry of await this.state.list<Upload>(userID, UPLOADS)) {
467
+ const upload = entry.value
468
+ if (upload.phaseAt > stale) continue
469
+ // Taking the record first: a writer still alive fails its next CAS on it instead of racing us.
470
+ if (!(await this.state.delete(userID, UPLOADS, entry.key, { ifVersion: entry.version })).ok) continue
471
+ uploadsRecovered++
472
+ const published = await this.isPublished(userID, upload)
473
+ const holder = pendingHolder(upload.artifactID, upload.revisionID)
474
+ for (const ref of await this.state.list<StoredRefs>(userID, BLOB_REFS)) {
475
+ if (!ref.value.holders.some((h) => h.id === holder)) continue
476
+ await this.changeHolders(userID, ref.key, (holders) => [...holders.filter((h) => h.id !== holder && h.id !== upload.artifactID), ...(published ? [{ id: upload.artifactID, at: this.now() }] : [])])
477
+ }
478
+ if (!published && (await this.removeRevisionRecord(userID, upload))) revisionsRemoved++
479
+ }
480
+ const uploads = new Set((await this.state.list<Upload>(userID, UPLOADS)).map((e) => e.key))
481
+ const artifacts = new Set((await this.state.list<StoredArtifact>(userID, ARTIFACTS)).map((e) => e.key))
482
+ for (const ref of await this.state.list<StoredRefs>(userID, BLOB_REFS)) {
483
+ // Only holders older than the grace period: a fresh one may belong to a write after our snapshots.
484
+ const dangling = (h: Holder): boolean => {
485
+ if (h.at > stale) return false
486
+ const slash = h.id.indexOf("/")
487
+ return slash < 0 ? !artifacts.has(h.id) : !uploads.has(h.id.slice(slash + 1))
488
+ }
489
+ if (!ref.value.holders.some(dangling)) continue
490
+ const before = ref.value.holders.length
491
+ const left = await this.changeHolders(userID, ref.key, (holders) => holders.filter((h) => !dangling(h)))
492
+ holdersRemoved += Math.max(0, before - left)
493
+ }
494
+ let cursor: string | undefined
495
+ do {
496
+ const page = await this.blobs.list(userID, { limit: LIST_LIMIT_MAX, ...(cursor !== undefined ? { cursor } : {}) })
497
+ for (const blob of page.items) if (await this.deleteIfUnreferenced(userID, blob.hash)) blobsDeleted++
498
+ cursor = page.cursor
499
+ } while (cursor !== undefined)
500
+ return { artifactsDeleted, uploadsRecovered, revisionsRemoved, holdersRemoved, blobsDeleted }
501
+ }
502
+
503
+ // ---- internals ------------------------------------------------------------------------------------------
504
+
505
+ /** The steps of "commit before publish" (see the header); `commit` is step 6 and the only publishing write. */
506
+ private async publish(
507
+ userID: string,
508
+ artifactID: string,
509
+ number: number,
510
+ baseRevisionID: string | undefined,
511
+ input: RevisionInput,
512
+ author: ArtifactRevision["author"],
513
+ commit: (revision: ArtifactRevision) => Promise<Artifact>,
514
+ ): Promise<Published> {
515
+ const revisionID = this.newID("revision")
516
+ const startedAt = this.now()
517
+ const upload: Upload = { artifactID, revisionID, number, phase: "storing", startedAt, phaseAt: startedAt }
518
+ const recorded = await this.state.put<Upload>(userID, UPLOADS, revisionID, upload, { ifVersion: 0 })
519
+ if (!recorded.ok) throw conflict("Revision ID collision; retry", undefined)
520
+ let uploadVersion = recorded.version
521
+ /** Keeps the upload alive: the grace period counts from its latest progress, not its start. */
522
+ const progress = async (phase: Upload["phase"]): Promise<number> => {
523
+ const at = this.now()
524
+ const written = await this.state.put<Upload>(userID, UPLOADS, revisionID, { ...upload, phase, phaseAt: at }, { ifVersion: uploadVersion })
525
+ if (!written.ok) throw conflict("The upload expired; retry", undefined)
526
+ uploadVersion = written.version
527
+ return at
528
+ }
529
+ const holder = pendingHolder(artifactID, revisionID)
530
+ const held = new Set<string>()
531
+ /** Past step 6's write: its outcome may be unknown, so only recover() may undo anything. */
532
+ let committing = false
533
+ try {
534
+ const refs: BlobRef[] = []
535
+ for (const file of input.files) {
536
+ const ref = await this.storeFile(userID, file)
537
+ if (!held.has(ref.hash)) {
538
+ await this.changeHolders(userID, ref.hash, (holders) => [...holders.filter((h) => h.id !== holder), { id: holder, at: this.now() }])
539
+ held.add(ref.hash)
540
+ }
541
+ refs.push(ref)
542
+ if (input.files.length > 1) await progress("storing")
543
+ }
544
+ for (const hash of held) {
545
+ if (!(await this.blobs.touch(userID, hash))) throw conflict("A blob was removed during the upload; retry", undefined)
546
+ }
547
+ const publishingAt = await progress("publishing")
548
+ const revision: ArtifactRevision = {
549
+ v: 1,
550
+ id: revisionID,
551
+ artifactID,
552
+ number,
553
+ ...(baseRevisionID !== undefined ? { baseRevisionID } : {}),
554
+ blobs: refs,
555
+ ...(input.entry !== undefined ? { entry: input.entry } : {}),
556
+ author,
557
+ createdAt: publishingAt,
558
+ }
559
+ await this.writeRevision(userID, revision)
560
+ if (this.now() - publishingAt > this.graceMs / 2) throw conflict("The upload expired; retry", undefined)
561
+ committing = true
562
+ let artifact: Artifact
563
+ try {
564
+ artifact = await commit(revision)
565
+ } catch (error) {
566
+ // An AgentError is a decided refusal (nothing written); anything else may have committed.
567
+ if (error instanceof AgentError) committing = false
568
+ throw error
569
+ }
570
+ try {
571
+ for (const hash of held) {
572
+ await this.changeHolders(userID, hash, (holders) => [...holders.filter((h) => h.id !== holder && h.id !== artifactID), { id: artifactID, at: this.now() }])
573
+ }
574
+ await this.state.delete(userID, UPLOADS, revisionID)
575
+ } catch {
576
+ // Published already; recover() finalizes the holders and the upload record after the grace period.
577
+ }
578
+ return { artifact, revision }
579
+ } catch (error) {
580
+ if (!committing) await this.abandon(userID, upload, held).catch(() => {})
581
+ throw error
582
+ }
583
+ }
584
+
585
+ /** Undoes an upload known not to have committed: its record, its holders and its revision record. */
586
+ private async abandon(userID: string, upload: Upload, held: Iterable<string>): Promise<void> {
587
+ const holder = pendingHolder(upload.artifactID, upload.revisionID)
588
+ for (const hash of held) await this.changeHolders(userID, hash, (holders) => holders.filter((h) => h.id !== holder))
589
+ await this.removeRevisionRecord(userID, upload)
590
+ await this.state.delete(userID, UPLOADS, upload.revisionID)
591
+ }
592
+
593
+ /** Writes the revision record create-only; replaces a stale one left by a crashed writer, else 409 conflict. */
594
+ private async writeRevision(userID: string, revision: ArtifactRevision): Promise<void> {
595
+ const collection = revisionsCollection(revision.artifactID)
596
+ const key = revisionKey(revision.number)
597
+ for (let attempt = 0; attempt < 2; attempt++) {
598
+ if ((await this.state.put(userID, collection, key, revision, { ifVersion: 0 })).ok) return
599
+ const existing = await this.state.get<ArtifactRevision>(userID, collection, key)
600
+ if (!existing) continue
601
+ const stored = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, revision.artifactID))?.value
602
+ if (stored && stored.head >= revision.number) throw conflict("baseRevisionID is not the latest revision", stored)
603
+ const other = (await this.state.get<Upload>(userID, UPLOADS, existing.value.id))?.value
604
+ if (other && other.phaseAt > this.now() - this.graceMs) throw conflict("Another revision on this base is being published", stored)
605
+ await this.state.delete(userID, collection, key, { ifVersion: existing.version })
606
+ }
607
+ throw conflict("Another revision on this base is being published", undefined)
608
+ }
609
+
610
+ private async removeRevisionRecord(userID: string, upload: Upload): Promise<boolean> {
611
+ const collection = revisionsCollection(upload.artifactID)
612
+ const key = revisionKey(upload.number)
613
+ const existing = await this.state.get<ArtifactRevision>(userID, collection, key)
614
+ if (existing?.value.id !== upload.revisionID) return false
615
+ const stored = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, upload.artifactID))?.value
616
+ if (stored && stored.head >= upload.number && stored.deletingAt === undefined) return false
617
+ return (await this.state.delete(userID, collection, key, { ifVersion: existing.version })).ok
618
+ }
619
+
620
+ private async isPublished(userID: string, upload: Upload): Promise<boolean> {
621
+ const stored = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, upload.artifactID))?.value
622
+ if (!stored || stored.deletingAt !== undefined || stored.head < upload.number) return false
623
+ const record = await this.state.get<ArtifactRevision>(userID, revisionsCollection(upload.artifactID), revisionKey(upload.number))
624
+ return record?.value.id === upload.revisionID
625
+ }
626
+
627
+ /** Stores one file's bytes (or checks a reused blob) and answers its reference; BlobStore errors become agent errors. */
628
+ private async storeFile(userID: string, file: RevisionFile): Promise<BlobRef> {
629
+ if ("ref" in file) {
630
+ const info = await this.blobs.stat(userID, file.ref.hash)
631
+ if (!info || info.bytes !== file.ref.bytes) throw invalid(`blob ${file.ref.hash} is not stored`)
632
+ return { hash: file.ref.hash, bytes: file.ref.bytes, mediaType: file.ref.mediaType, ...(file.ref.path !== undefined ? { path: file.ref.path } : {}) }
633
+ }
634
+ try {
635
+ const ref = await this.blobs.put(userID, file.body, file.mediaType, {
636
+ maxBytes: this.limits.blobBytes,
637
+ quotaBytes: this.limits.userBlobBytes,
638
+ ...(file.expectedHash !== undefined ? { expectedHash: file.expectedHash } : {}),
639
+ })
640
+ return { ...ref, ...(file.path !== undefined ? { path: file.path } : {}) }
641
+ } catch (error) {
642
+ if (error instanceof BlobQuotaError) throw capReached("Blob storage", error.quotaBytes)
643
+ if (error instanceof BlobTooLargeError || error instanceof BlobHashMismatchError || error instanceof BlobInputError) throw invalid(error.message)
644
+ throw error
645
+ }
646
+ }
647
+
648
+ /** Request checks before any bytes are read: counts, media types, paths, the entry. */
649
+ private checkRevisionInput(input: RevisionInput): void {
650
+ const files = input.files
651
+ if (!Array.isArray(files) || files.length < 1) throw invalid("a revision needs at least one file")
652
+ if (files.length > this.limits.bundleFiles) throw invalid(`a revision has at most ${this.limits.bundleFiles} files`)
653
+ const paths = new Set<string>()
654
+ for (const file of files) {
655
+ const ref = "ref" in file ? file.ref : { hash: file.expectedHash ?? NO_HASH, bytes: 0, mediaType: file.mediaType, ...(file.path !== undefined ? { path: file.path } : {}) }
656
+ const problem = blobRefProblem(ref)
657
+ if (problem) throw invalid(problem)
658
+ if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(ref.mediaType)) throw invalid("mediaType is not allowed")
659
+ if (ref.path !== undefined) {
660
+ if (paths.has(ref.path)) throw invalid(`path ${ref.path} appears twice`)
661
+ paths.add(ref.path)
662
+ } else if (files.length > 1) {
663
+ throw invalid("every file of a bundle needs a path")
664
+ }
665
+ }
666
+ if (input.entry !== undefined && !paths.has(input.entry)) throw invalid("entry must be the path of one of the files")
667
+ }
668
+
669
+ /** Read-modify-write of the holders of a blob (deleting the record when none are left); resolves with how many are left. */
670
+ private async changeHolders(userID: string, hash: string, change: (holders: readonly Holder[]) => Holder[]): Promise<number> {
671
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
672
+ const current = await this.state.get<StoredRefs>(userID, BLOB_REFS, hash)
673
+ const holders = change(current?.value.holders ?? [])
674
+ if (holders.length === 0) {
675
+ if (!current) return 0
676
+ if ((await this.state.delete(userID, BLOB_REFS, hash, { ifVersion: current.version })).ok) return 0
677
+ } else if ((await this.state.put<StoredRefs>(userID, BLOB_REFS, hash, { holders }, { ifVersion: current?.version ?? 0 })).ok) {
678
+ return holders.length
679
+ }
680
+ }
681
+ throw new Error(`blob references of ${hash} kept conflicting`)
682
+ }
683
+
684
+ /** Deletes the bytes when nothing holds them and nobody touched them within the grace period. */
685
+ private async deleteIfUnreferenced(userID: string, hash: string): Promise<boolean> {
686
+ const readAt = this.now()
687
+ const refs = await this.state.get<StoredRefs>(userID, BLOB_REFS, hash)
688
+ if (refs && refs.value.holders.length > 0) return false
689
+ return this.blobs.delete(userID, hash, { touchedBefore: readAt - this.graceMs })
690
+ }
691
+
692
+ /** The rest of a delete after the tombstone: holders, unreferenced bytes, revision records, the record. */
693
+ private async finishDelete(userID: string, artifactID: string): Promise<void> {
694
+ const collection = revisionsCollection(artifactID)
695
+ const revisions = await this.state.list<ArtifactRevision>(userID, collection)
696
+ const hashes = new Set(revisions.flatMap((r) => r.value.blobs.map((b) => b.hash)))
697
+ for (const hash of hashes) {
698
+ const left = await this.changeHolders(userID, hash, (holders) => holders.filter((h) => h.id !== artifactID && !h.id.startsWith(`${artifactID}/`)))
699
+ if (left === 0) await this.deleteIfUnreferenced(userID, hash)
700
+ }
701
+ for (const entry of revisions) await this.state.delete(userID, collection, entry.key)
702
+ await this.state.delete(userID, ARTIFACTS, artifactID)
703
+ }
704
+
705
+ private async setState(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number, to: RecordState): Promise<Artifact> {
706
+ const artifact = await this.mutate(userID, principal, id, expectedRevision, async (s) => {
707
+ const { archivedAt: _, ...rest } = s
708
+ return { ...rest, ...(to === "archived" ? { archivedAt: this.now() } : {}), artifact: { ...s.artifact, state: to } }
709
+ })
710
+ this.emit(userID, { type: "artifact.archived", artifactID: id, kind: artifact.kind, revision: artifact.revision, state: to })
711
+ return artifact
712
+ }
713
+
714
+ /** A versioned change of the artifact record by a principal allowed to manage it. */
715
+ private async mutate(
716
+ userID: string,
717
+ principal: ArtifactPrincipal,
718
+ id: string,
719
+ expectedRevision: number,
720
+ change: (stored: StoredArtifact) => Promise<StoredArtifact>,
721
+ ): Promise<Artifact> {
722
+ if (!isExpectedRevision(expectedRevision)) throw invalid("expectedRevision must be a non-negative integer")
723
+ const { stored, version } = await this.load(userID, principal, id, "manage")
724
+ if (stored.artifact.revision !== expectedRevision) throw conflict("expectedRevision is not the current revision", stored)
725
+ const changed = await change(stored)
726
+ const next: StoredArtifact = { ...changed, artifact: { ...changed.artifact, revision: stored.artifact.revision + 1, updatedAt: this.now() } }
727
+ const written = await this.state.put<StoredArtifact>(userID, ARTIFACTS, id, next, { ifVersion: version })
728
+ if (!written.ok) {
729
+ const current = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, id))?.value
730
+ throw conflict("expectedRevision is not the current revision", current)
731
+ }
732
+ return next.artifact
733
+ }
734
+
735
+ /** The record, if the principal may read it (else not_found) and do `action` (else forbidden). */
736
+ private async load(userID: string, principal: ArtifactPrincipal, id: string, action: ArtifactAction): Promise<{ stored: StoredArtifact; version: number }> {
737
+ if (!isContractID("artifact", id)) throw notFound()
738
+ const entry = await this.state.get<StoredArtifact>(userID, ARTIFACTS, id)
739
+ if (!entry || entry.value.deletingAt !== undefined) throw notFound()
740
+ if (!(await this.allowed(userID, principal, "read", entry.value.artifact))) throw notFound()
741
+ if (action !== "read" && !(await this.allowed(userID, principal, action, entry.value.artifact))) throw forbidden()
742
+ return { stored: entry.value, version: entry.version }
743
+ }
744
+
745
+ private allowed(userID: string, principal: ArtifactPrincipal, action: ArtifactAction, artifact?: Artifact): Promise<boolean> {
746
+ return this.authorize({ userID, principal, action, ...(artifact ? { artifact } : {}), ...(this.workspaces ? { workspaces: this.workspaces } : {}) })
747
+ }
748
+
749
+ private async visibleRevisions(userID: string, stored: StoredArtifact): Promise<ArtifactRevision[]> {
750
+ return (await this.state.list<ArtifactRevision>(userID, revisionsCollection(stored.artifact.id)))
751
+ .map((e) => e.value)
752
+ .filter((r) => r.number <= stored.head)
753
+ .sort((a, b) => b.number - a.number)
754
+ }
755
+
756
+ private async checkWorkspace(userID: string, workspaceID: string): Promise<void> {
757
+ if (!this.workspaces) throw new AgentError("unsupported", "Workspaces are not available on this hub", { extra: { feature: "workspaces" } })
758
+ if (!(await this.workspaces.exists(userID, workspaceID))) throw invalid("workspaceID is not a workspace of this user")
759
+ }
760
+
761
+ private emit(userID: string, event: ArtifactEvent): void {
762
+ try {
763
+ this.onEvent?.(userID, event)
764
+ } catch {
765
+ // An event sink never fails the write that happened.
766
+ }
767
+ }
768
+ }
769
+
770
+ function authorOf(principal: ArtifactPrincipal): ArtifactRevision["author"] {
771
+ return principal.kind === "session" ? { kind: "session", sessionID: principal.sessionID } : { kind: "user" }
772
+ }
773
+
774
+ function dropUndefined<T extends object>(value: T): T {
775
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
776
+ }