@defrex/autobuild 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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,1138 @@
1
+ /**
2
+ * The in-memory BuildStore — the reference adapter. Its behavior *is* the
3
+ * contract: the suite in `store/contract.ts` is written against this and
4
+ * every other adapter (SQLite, remote HTTP) must match it.
5
+ */
6
+ import {
7
+ validateEventWrite,
8
+ type AbEvent,
9
+ type EventEnvelope,
10
+ type EventWrite,
11
+ } from '../events/catalog'
12
+ import type { EventType } from '../events/payloads'
13
+ import {
14
+ validateRepositoryEventWrite,
15
+ type RepositoryEvent,
16
+ type RepositoryEventEnvelope,
17
+ type RepositoryEventType,
18
+ type RepositoryEventWrite,
19
+ } from '../events/repository'
20
+ import {
21
+ validateSessionEventWrite,
22
+ type SessionEvent,
23
+ type SessionEventEnvelope,
24
+ type SessionEventType,
25
+ type SessionEventWrite,
26
+ } from '../events/sessions'
27
+ import { humanActor } from '../events/envelope'
28
+ import { createBuildScopedStore } from './build-scope'
29
+ import { createSessionScopedStore } from './session-handle'
30
+ import {
31
+ DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
32
+ isRetentionManagedKind,
33
+ revisionsToPrune,
34
+ } from './retention'
35
+ import { pollingSubscribe } from './subscribe'
36
+ import { StreamLocks } from './streams/lock'
37
+ import { assembleUIMessageDocument } from './streams/assemble'
38
+ import { EVENT_WAIT_POLL_MS, readEventsWithWait, readStreamWithWait } from './streams/wait'
39
+ import {
40
+ serializedBatchSize,
41
+ STREAM_BATCH_MAX_BYTES,
42
+ STREAM_FORMAT,
43
+ StreamBatchTooLargeError,
44
+ StreamClosedError,
45
+ streamArtifactInput,
46
+ validateStreamParts,
47
+ type StreamChunk,
48
+ type StreamOutcome,
49
+ type StreamPart,
50
+ type StreamRead,
51
+ type StreamRecord,
52
+ type StreamScope,
53
+ } from './streams/types'
54
+ import {
55
+ contentHash,
56
+ normalizeOperator,
57
+ systemClock,
58
+ toBytes,
59
+ validateExpectedSeq,
60
+ type Artifact,
61
+ type ArtifactInput,
62
+ type ArtifactMeta,
63
+ type BlobStore,
64
+ type BuildRecord,
65
+ type BuildScopedStore,
66
+ type BuildStore,
67
+ type Clock,
68
+ type NewBuildInput,
69
+ type NewSessionInput,
70
+ type RepositoryArtifact,
71
+ type RepositoryArtifactMeta,
72
+ type RepositoryRecord,
73
+ type SessionArtifact,
74
+ type SessionArtifactMeta,
75
+ type SessionRecord,
76
+ type SessionScopedStore,
77
+ type SubscribeOptions,
78
+ type Unsubscribe,
79
+ } from './types'
80
+
81
+ export class MemoryBlobStore implements BlobStore {
82
+ private readonly blobs = new Map<string, Uint8Array>()
83
+
84
+ async put(hash: string, bytes: Uint8Array): Promise<void> {
85
+ this.blobs.set(hash, bytes.slice())
86
+ }
87
+
88
+ async get(hash: string): Promise<Uint8Array | null> {
89
+ const bytes = this.blobs.get(hash)
90
+ return bytes ? bytes.slice() : null
91
+ }
92
+ }
93
+
94
+ interface Lease {
95
+ holder: string
96
+ expiresAt: number
97
+ ttlMs: number
98
+ }
99
+
100
+ interface RepoState {
101
+ record: {
102
+ repo: string
103
+ createdAt: string
104
+ updatedAt: string
105
+ heartbeatAt?: string
106
+ }
107
+ lease?: Lease
108
+ events: RepositoryEvent[]
109
+ artifacts: Map<string, RepositoryArtifactMeta[]>
110
+ }
111
+
112
+ interface BuildState {
113
+ record: {
114
+ slug: string
115
+ repo: string
116
+ repoOrigin?: NewBuildInput['repoOrigin']
117
+ ticket?: NewBuildInput['ticket']
118
+ branch?: string
119
+ createdAt: string
120
+ updatedAt: string
121
+ heartbeatAt?: string
122
+ }
123
+ lease?: Lease
124
+ events: AbEvent[]
125
+ /** kind → deposits in revision order (index = revision; 0-based, §6.3). */
126
+ artifacts: Map<string, ArtifactMeta[]>
127
+ }
128
+
129
+ interface StreamState {
130
+ record: StreamRecord
131
+ chunks: StreamChunk[]
132
+ }
133
+
134
+ interface SessionState {
135
+ record: SessionRecord
136
+ events: SessionEvent[]
137
+ /** kind → deposits in revision order (index = revision; 0-based, §6.3). */
138
+ artifacts: Map<string, SessionArtifactMeta[]>
139
+ }
140
+
141
+ export class MemoryBuildStore implements BuildStore {
142
+ private readonly builds = new Map<string, BuildState>()
143
+ private readonly repos = new Map<string, RepoState>()
144
+ private readonly streams = new Map<string, StreamState>()
145
+ /** Per-stream in-process mutex (store/streams/lock.ts): serializes a
146
+ * stream's close against its appends inside this process (AUT-348). */
147
+ private readonly streamLocks = new StreamLocks()
148
+ private readonly sessions = new Map<string, SessionState>()
149
+ private readonly clock: Clock
150
+ private readonly maxRevisions: number
151
+ readonly blobs: BlobStore
152
+
153
+ constructor(
154
+ opts: {
155
+ clock?: Clock
156
+ blobs?: BlobStore
157
+ retention?: { maxRevisions?: number }
158
+ } = {},
159
+ ) {
160
+ this.clock = opts.clock ?? systemClock
161
+ this.blobs = opts.blobs ?? new MemoryBlobStore()
162
+ this.maxRevisions = opts.retention?.maxRevisions ?? DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS
163
+ }
164
+
165
+ /** Newest surviving revision of one kind — revisions prune from the front,
166
+ * so the array length is no longer the next revision (retention policy). */
167
+ private static nextRevision(revs: { revision: number }[] | undefined): number {
168
+ return (revs?.at(-1)?.revision ?? -1) + 1
169
+ }
170
+
171
+ /** Deposit-time pruning (store/retention.ts): drop revisions past the bound
172
+ * for retention-managed kinds, inside the same critical section as the
173
+ * deposit itself. Non-family kinds are never touched. */
174
+ private pruneBuildKind(state: BuildState, kind: string): void {
175
+ if (!isRetentionManagedKind(kind)) return
176
+ const revs = state.artifacts.get(kind)
177
+ if (!revs) return
178
+ const pruned = revisionsToPrune(
179
+ revs.map((meta) => meta.revision),
180
+ this.maxRevisions,
181
+ )
182
+ if (pruned.length === 0) return
183
+ const drop = new Set(pruned)
184
+ state.artifacts.set(
185
+ kind,
186
+ revs.filter((meta) => !drop.has(meta.revision)),
187
+ )
188
+ }
189
+
190
+ private pruneRepoKind(state: RepoState, kind: string): void {
191
+ if (!isRetentionManagedKind(kind)) return
192
+ const revisions = state.artifacts.get(kind)
193
+ if (!revisions) return
194
+ const pruned = revisionsToPrune(
195
+ revisions.map((meta) => meta.revision),
196
+ this.maxRevisions,
197
+ )
198
+ if (pruned.length === 0) return
199
+ const drop = new Set(pruned)
200
+ state.artifacts.set(
201
+ kind,
202
+ revisions.filter((meta) => !drop.has(meta.revision)),
203
+ )
204
+ }
205
+
206
+ scopeBuild(slug: string): BuildScopedStore {
207
+ return createBuildScopedStore(this, slug)
208
+ }
209
+
210
+ scopeSession(id: string): SessionScopedStore {
211
+ return createSessionScopedStore(this, id)
212
+ }
213
+
214
+ private now(): string {
215
+ return this.clock().toISOString()
216
+ }
217
+
218
+ private state(slug: string): BuildState {
219
+ const state = this.builds.get(slug)
220
+ if (!state) throw new Error(`unknown build "${slug}"`)
221
+ return state
222
+ }
223
+
224
+ private snapshot(state: BuildState): BuildRecord {
225
+ const { record, lease } = state
226
+ return {
227
+ slug: record.slug,
228
+ repo: record.repo,
229
+ ...(record.ticket ? { ticket: structuredClone(record.ticket) } : {}),
230
+ ...(record.branch ? { branch: record.branch } : {}),
231
+ ...(record.repoOrigin !== undefined ? { repoOrigin: record.repoOrigin } : {}),
232
+ createdAt: record.createdAt,
233
+ updatedAt: record.updatedAt,
234
+ ...(record.heartbeatAt ? { heartbeatAt: record.heartbeatAt } : {}),
235
+ ...(lease
236
+ ? {
237
+ lease: {
238
+ holder: lease.holder,
239
+ expiresAt: new Date(lease.expiresAt).toISOString(),
240
+ },
241
+ }
242
+ : {}),
243
+ }
244
+ }
245
+
246
+ async createBuild(input: NewBuildInput): Promise<BuildRecord> {
247
+ if (this.builds.has(input.slug)) {
248
+ throw new Error(`build "${input.slug}" already exists`)
249
+ }
250
+ const ts = this.now()
251
+ const state: BuildState = {
252
+ record: {
253
+ slug: input.slug,
254
+ repo: input.repo,
255
+ ...(input.repoOrigin !== undefined ? { repoOrigin: input.repoOrigin } : {}),
256
+ ...(input.ticket ? { ticket: structuredClone(input.ticket) } : {}),
257
+ ...(input.branch ? { branch: input.branch } : {}),
258
+ createdAt: ts,
259
+ updatedAt: ts,
260
+ },
261
+ events: [],
262
+ artifacts: new Map(),
263
+ }
264
+ this.builds.set(input.slug, state)
265
+ return this.snapshot(state)
266
+ }
267
+
268
+ async getBuild(slug: string): Promise<BuildRecord | null> {
269
+ const state = this.builds.get(slug)
270
+ return state ? this.snapshot(state) : null
271
+ }
272
+
273
+ async listBuilds(): Promise<BuildRecord[]> {
274
+ return [...this.builds.values()].map((state) => this.snapshot(state))
275
+ }
276
+
277
+ async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
278
+ const state = this.state(slug)
279
+ const validated = validateEventWrite(event)
280
+ const envelope = {
281
+ build: slug,
282
+ seq: state.events.length + 1,
283
+ ts: this.now(),
284
+ actor: validated.actor,
285
+ type: validated.type,
286
+ payload: validated.payload,
287
+ } as EventEnvelope<T>
288
+ state.events.push(structuredClone(envelope) as AbEvent)
289
+ state.record.updatedAt = envelope.ts
290
+ return envelope
291
+ }
292
+
293
+ async appendIfCurrent<T extends EventType>(
294
+ slug: string,
295
+ expectedSeq: number,
296
+ event: EventWrite<T>,
297
+ ): Promise<EventEnvelope<T> | null> {
298
+ const state = this.state(slug)
299
+ validateExpectedSeq(expectedSeq)
300
+ const validated = validateEventWrite(event)
301
+
302
+ // No await occurs between comparison and mutation. JavaScript's run-to-
303
+ // completion semantics make this one atomic critical section even when
304
+ // concurrent remote requests interleave elsewhere in the adapter.
305
+ if (state.events.length !== expectedSeq) return null
306
+ const envelope = {
307
+ build: slug,
308
+ seq: expectedSeq + 1,
309
+ ts: this.now(),
310
+ actor: validated.actor,
311
+ type: validated.type,
312
+ payload: validated.payload,
313
+ } as EventEnvelope<T>
314
+ state.events.push(structuredClone(envelope) as AbEvent)
315
+ state.record.updatedAt = envelope.ts
316
+ return envelope
317
+ }
318
+
319
+ async appendWithArtifacts<T extends EventType>(
320
+ slug: string,
321
+ artifacts: ArtifactInput[],
322
+ makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
323
+ ): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
324
+ const state = this.state(slug)
325
+ // Prepare phase — validate every input and store the blobs *before*
326
+ // touching build state, mirroring the SQLite adapter: an invalid input
327
+ // mid-bundle persists nothing, and orphaned blobs are harmless because
328
+ // they are content-addressed (D6, §8.5).
329
+ const prepared: { kind: string; blobRef: string; metadata: Record<string, unknown> }[] = []
330
+ for (const artifact of artifacts) {
331
+ if (!artifact.kind) throw new Error('artifact kind is required')
332
+ const bytes = toBytes(artifact.content)
333
+ const blobRef = contentHash(bytes)
334
+ await this.blobs.put(blobRef, bytes)
335
+ prepared.push({
336
+ kind: artifact.kind,
337
+ blobRef,
338
+ metadata: structuredClone(artifact.metadata ?? {}),
339
+ })
340
+ }
341
+ // Commit phase — fully synchronous (no await), so no interleaved writer
342
+ // can slip between revision assignment, event validation, and the
343
+ // deposits landing: "there is no state where an artifact exists without
344
+ // its event or vice versa" (D6, §8.5). Everything is validated before
345
+ // the first mutation, so no rollback path exists to get wrong.
346
+ //
347
+ // Ordering invariant (AUT-322): event validation precedes any retention
348
+ // prune — validation runs before the deposit loop below, and the per-kind
349
+ // prune happens inside that loop after the deposit, so a same-kind batch
350
+ // whose prune scope covers a sibling never deletes that sibling before
351
+ // the batch's event is validated.
352
+ const ts = this.now()
353
+ const nextRev = new Map<string, number>()
354
+ const deposited: ArtifactMeta[] = prepared.map((p) => {
355
+ const revision =
356
+ nextRev.get(p.kind) ?? MemoryBuildStore.nextRevision(state.artifacts.get(p.kind))
357
+ nextRev.set(p.kind, revision + 1)
358
+ return {
359
+ build: slug,
360
+ kind: p.kind,
361
+ revision,
362
+ blobRef: p.blobRef,
363
+ metadata: p.metadata,
364
+ createdAt: ts,
365
+ }
366
+ })
367
+ const validated = validateEventWrite(makeEvent(structuredClone(deposited)))
368
+ for (const meta of deposited) {
369
+ const revs = state.artifacts.get(meta.kind) ?? []
370
+ revs.push(meta)
371
+ state.artifacts.set(meta.kind, revs)
372
+ this.pruneBuildKind(state, meta.kind)
373
+ }
374
+ const envelope = {
375
+ build: slug,
376
+ seq: state.events.length + 1,
377
+ ts,
378
+ actor: validated.actor,
379
+ type: validated.type,
380
+ payload: validated.payload,
381
+ } as EventEnvelope<T>
382
+ state.events.push(structuredClone(envelope) as AbEvent)
383
+ state.record.updatedAt = ts
384
+ return { event: envelope, artifacts: structuredClone(deposited) }
385
+ }
386
+
387
+ async getEvents(
388
+ slug: string,
389
+ sinceSeq = 0,
390
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
391
+ ): Promise<AbEvent[]> {
392
+ return readEventsWithWait({
393
+ read: async () => {
394
+ const state = this.state(slug)
395
+ return structuredClone(state.events.filter((e) => e.seq > sinceSeq)) as AbEvent[]
396
+ },
397
+ waitSeconds: opts?.waitSeconds,
398
+ signal: opts?.signal,
399
+ // Hosted budget: held build-event reads poll at most once per second
400
+ // (AUT-383) — not the 25 ms stream default, which is presentation
401
+ // content's cadence.
402
+ pollMs: EVENT_WAIT_POLL_MS,
403
+ })
404
+ }
405
+
406
+ async putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
407
+ const state = this.state(slug)
408
+ if (!artifact.kind) throw new Error('artifact kind is required')
409
+ const bytes = toBytes(artifact.content)
410
+ const blobRef = contentHash(bytes)
411
+ await this.blobs.put(blobRef, bytes)
412
+ const revs = state.artifacts.get(artifact.kind) ?? []
413
+ const meta: ArtifactMeta = {
414
+ build: slug,
415
+ kind: artifact.kind,
416
+ revision: MemoryBuildStore.nextRevision(revs),
417
+ blobRef,
418
+ metadata: structuredClone(artifact.metadata ?? {}),
419
+ createdAt: this.now(),
420
+ }
421
+ revs.push(meta)
422
+ state.artifacts.set(artifact.kind, revs)
423
+ this.pruneBuildKind(state, artifact.kind)
424
+ state.record.updatedAt = meta.createdAt
425
+ return structuredClone(meta)
426
+ }
427
+
428
+ async getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
429
+ const state = this.state(slug)
430
+ const revs = state.artifacts.get(kind)
431
+ if (!revs || revs.length === 0) return null
432
+ const meta =
433
+ rev === undefined ? revs.at(-1) : revs.find((candidate) => candidate.revision === rev)
434
+ if (!meta) return null
435
+ const content = await this.blobs.get(meta.blobRef)
436
+ if (!content) return null
437
+ return { meta: structuredClone(meta), content }
438
+ }
439
+
440
+ async listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
441
+ const state = this.state(slug)
442
+ const all = [...state.artifacts.values()].flat()
443
+ const filtered = kind ? all.filter((meta) => meta.kind === kind) : all
444
+ return structuredClone(
445
+ filtered.sort((a, b) => a.kind.localeCompare(b.kind) || a.revision - b.revision),
446
+ )
447
+ }
448
+
449
+ async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
450
+ const state = this.state(slug)
451
+ const now = this.clock().getTime()
452
+ const lease = state.lease
453
+ if (lease && lease.holder !== holder && lease.expiresAt > now) {
454
+ return false
455
+ }
456
+ state.lease = { holder, expiresAt: now + ttlMs, ttlMs }
457
+ state.record.updatedAt = new Date(now).toISOString()
458
+ return true
459
+ }
460
+
461
+ async heartbeat(slug: string, holder: string): Promise<boolean> {
462
+ const state = this.state(slug)
463
+ const now = this.clock().getTime()
464
+ const lease = state.lease
465
+ if (!lease || lease.holder !== holder || lease.expiresAt <= now) {
466
+ return false
467
+ }
468
+ lease.expiresAt = now + lease.ttlMs
469
+ state.record.heartbeatAt = new Date(now).toISOString()
470
+ state.record.updatedAt = state.record.heartbeatAt
471
+ return true
472
+ }
473
+
474
+ async releaseLease(slug: string, holder: string): Promise<void> {
475
+ const state = this.state(slug)
476
+ if (state.lease?.holder === holder) {
477
+ state.lease = undefined
478
+ state.record.updatedAt = this.now()
479
+ }
480
+ }
481
+
482
+ subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
483
+ return pollingSubscribe((since) => this.getEvents(slug, since), opts, onEvent)
484
+ }
485
+
486
+ private repoState(repo: string): RepoState {
487
+ const state = this.repos.get(repo)
488
+ if (!state) throw new Error(`unknown repo "${repo}"`)
489
+ return state
490
+ }
491
+
492
+ private repoSnapshot(state: RepoState): RepositoryRecord {
493
+ return {
494
+ repo: state.record.repo,
495
+ createdAt: state.record.createdAt,
496
+ updatedAt: state.record.updatedAt,
497
+ ...(state.record.heartbeatAt ? { heartbeatAt: state.record.heartbeatAt } : {}),
498
+ ...(state.lease
499
+ ? {
500
+ lease: {
501
+ holder: state.lease.holder,
502
+ expiresAt: new Date(state.lease.expiresAt).toISOString(),
503
+ },
504
+ }
505
+ : {}),
506
+ }
507
+ }
508
+
509
+ async ensureRepo(repo: string): Promise<RepositoryRecord> {
510
+ if (!repo) throw new Error('repo is required')
511
+ let state = this.repos.get(repo)
512
+ if (!state) {
513
+ const ts = this.now()
514
+ state = {
515
+ record: { repo, createdAt: ts, updatedAt: ts },
516
+ events: [],
517
+ artifacts: new Map(),
518
+ }
519
+ this.repos.set(repo, state)
520
+ }
521
+ return this.repoSnapshot(state)
522
+ }
523
+
524
+ async getRepo(repo: string): Promise<RepositoryRecord | null> {
525
+ const state = this.repos.get(repo)
526
+ return state ? this.repoSnapshot(state) : null
527
+ }
528
+
529
+ async appendRepo<T extends RepositoryEventType>(
530
+ repo: string,
531
+ event: RepositoryEventWrite<T>,
532
+ ): Promise<RepositoryEventEnvelope<T>> {
533
+ const state = this.repoState(repo)
534
+ const validated = validateRepositoryEventWrite(event)
535
+ const envelope = {
536
+ repo,
537
+ seq: state.events.length + 1,
538
+ ts: this.now(),
539
+ actor: validated.actor,
540
+ type: validated.type,
541
+ payload: validated.payload,
542
+ } as RepositoryEventEnvelope<T>
543
+ state.events.push(structuredClone(envelope) as RepositoryEvent)
544
+ state.record.updatedAt = envelope.ts
545
+ return envelope
546
+ }
547
+
548
+ async appendRepoWithArtifacts<T extends RepositoryEventType>(
549
+ repo: string,
550
+ artifacts: ArtifactInput[],
551
+ makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
552
+ ): Promise<{
553
+ event: RepositoryEventEnvelope<T>
554
+ artifacts: RepositoryArtifactMeta[]
555
+ }> {
556
+ const state = this.repoState(repo)
557
+ const prepared: {
558
+ kind: string
559
+ blobRef: string
560
+ metadata: Record<string, unknown>
561
+ }[] = []
562
+ for (const artifact of artifacts) {
563
+ if (!artifact.kind) throw new Error('artifact kind is required')
564
+ const bytes = toBytes(artifact.content)
565
+ const blobRef = contentHash(bytes)
566
+ await this.blobs.put(blobRef, bytes)
567
+ prepared.push({
568
+ kind: artifact.kind,
569
+ blobRef,
570
+ metadata: structuredClone(artifact.metadata ?? {}),
571
+ })
572
+ }
573
+ const ts = this.now()
574
+ const nextRev = new Map<string, number>()
575
+ const deposited = prepared.map((item): RepositoryArtifactMeta => {
576
+ const revision =
577
+ nextRev.get(item.kind) ?? MemoryBuildStore.nextRevision(state.artifacts.get(item.kind))
578
+ nextRev.set(item.kind, revision + 1)
579
+ return {
580
+ repo,
581
+ kind: item.kind,
582
+ revision,
583
+ blobRef: item.blobRef,
584
+ metadata: item.metadata,
585
+ createdAt: ts,
586
+ }
587
+ })
588
+ // Ordering invariant (AUT-322): event validation precedes any retention
589
+ // prune — validation runs before the deposit loop below, and the
590
+ // per-kind prune happens inside that loop after the deposit (same shape
591
+ // as `appendWithArtifacts` on the build side).
592
+ const validated = validateRepositoryEventWrite(makeEvent(structuredClone(deposited)))
593
+ for (const meta of deposited) {
594
+ const revisions = state.artifacts.get(meta.kind) ?? []
595
+ revisions.push(meta)
596
+ state.artifacts.set(meta.kind, revisions)
597
+ this.pruneRepoKind(state, meta.kind)
598
+ }
599
+ const envelope = {
600
+ repo,
601
+ seq: state.events.length + 1,
602
+ ts,
603
+ actor: validated.actor,
604
+ type: validated.type,
605
+ payload: validated.payload,
606
+ } as RepositoryEventEnvelope<T>
607
+ state.events.push(structuredClone(envelope) as RepositoryEvent)
608
+ state.record.updatedAt = ts
609
+ return { event: envelope, artifacts: structuredClone(deposited) }
610
+ }
611
+
612
+ async getRepoEvents(
613
+ repo: string,
614
+ sinceSeq = 0,
615
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
616
+ ): Promise<RepositoryEvent[]> {
617
+ return readEventsWithWait({
618
+ read: async () => {
619
+ return structuredClone(
620
+ this.repoState(repo).events.filter((event) => event.seq > sinceSeq),
621
+ ) as RepositoryEvent[]
622
+ },
623
+ waitSeconds: opts?.waitSeconds,
624
+ signal: opts?.signal,
625
+ // Hosted budget: held repository-event reads poll at most once per
626
+ // second (AUT-383) — not the 25 ms stream default, which is
627
+ // presentation content's cadence.
628
+ pollMs: EVENT_WAIT_POLL_MS,
629
+ })
630
+ }
631
+
632
+ async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
633
+ const state = this.repoState(repo)
634
+ if (!artifact.kind) throw new Error('artifact kind is required')
635
+ const bytes = toBytes(artifact.content)
636
+ const blobRef = contentHash(bytes)
637
+ await this.blobs.put(blobRef, bytes)
638
+ const revisions = state.artifacts.get(artifact.kind) ?? []
639
+ const meta: RepositoryArtifactMeta = {
640
+ repo,
641
+ kind: artifact.kind,
642
+ revision: MemoryBuildStore.nextRevision(revisions),
643
+ blobRef,
644
+ metadata: structuredClone(artifact.metadata ?? {}),
645
+ createdAt: this.now(),
646
+ }
647
+ revisions.push(meta)
648
+ state.artifacts.set(artifact.kind, revisions)
649
+ this.pruneRepoKind(state, artifact.kind)
650
+ state.record.updatedAt = meta.createdAt
651
+ return structuredClone(meta)
652
+ }
653
+
654
+ async getRepoArtifact(
655
+ repo: string,
656
+ kind: string,
657
+ rev?: number,
658
+ ): Promise<RepositoryArtifact | null> {
659
+ const revisions = this.repoState(repo).artifacts.get(kind)
660
+ if (!revisions || revisions.length === 0) return null
661
+ const meta =
662
+ rev === undefined
663
+ ? revisions.at(-1)
664
+ : revisions.find((candidate) => candidate.revision === rev)
665
+ if (!meta) return null
666
+ const content = await this.blobs.get(meta.blobRef)
667
+ return content ? { meta: structuredClone(meta), content } : null
668
+ }
669
+
670
+ async listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
671
+ const all = [...this.repoState(repo).artifacts.values()].flat()
672
+ return structuredClone(
673
+ (kind ? all.filter((meta) => meta.kind === kind) : all).sort(
674
+ (a, b) => a.kind.localeCompare(b.kind) || a.revision - b.revision,
675
+ ),
676
+ )
677
+ }
678
+
679
+ async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
680
+ const state = this.repoState(repo)
681
+ const now = this.clock().getTime()
682
+ if (state.lease && state.lease.holder !== holder && state.lease.expiresAt > now) {
683
+ return false
684
+ }
685
+ state.lease = { holder, expiresAt: now + ttlMs, ttlMs }
686
+ state.record.updatedAt = new Date(now).toISOString()
687
+ return true
688
+ }
689
+
690
+ async heartbeatRepo(repo: string, holder: string): Promise<boolean> {
691
+ const state = this.repoState(repo)
692
+ const now = this.clock().getTime()
693
+ const lease = state.lease
694
+ if (!lease || lease.holder !== holder || lease.expiresAt <= now) return false
695
+ lease.expiresAt = now + lease.ttlMs
696
+ state.record.heartbeatAt = new Date(now).toISOString()
697
+ state.record.updatedAt = state.record.heartbeatAt
698
+ return true
699
+ }
700
+
701
+ async releaseRepoLease(repo: string, holder: string): Promise<void> {
702
+ const state = this.repoState(repo)
703
+ if (state.lease?.holder === holder) {
704
+ state.lease = undefined
705
+ state.record.updatedAt = this.now()
706
+ }
707
+ }
708
+
709
+ // ── Operator sessions (SPEC §7.1.1 — a third resource kind) ─────────
710
+
711
+ private sessionState(id: string): SessionState {
712
+ const state = this.sessions.get(id)
713
+ if (!state) throw new Error(`unknown session "${id}"`)
714
+ return state
715
+ }
716
+
717
+ private sessionSnapshot(state: SessionState): SessionRecord {
718
+ return structuredClone(state.record)
719
+ }
720
+
721
+ async createSession(input: NewSessionInput): Promise<SessionRecord> {
722
+ const operator = normalizeOperator(input.operator)
723
+ if (!input.repo) throw new Error('repo is required')
724
+ const ts = this.now()
725
+ const id = `os_${crypto.randomUUID()}`
726
+ const state: SessionState = {
727
+ record: {
728
+ id,
729
+ repo: input.repo,
730
+ operator,
731
+ ...(input.title !== undefined ? { title: input.title } : {}),
732
+ createdAt: ts,
733
+ updatedAt: ts,
734
+ },
735
+ events: [],
736
+ artifacts: new Map(),
737
+ }
738
+ // The record and its first fact land together: `session.created` (seq 1,
739
+ // actor the operator) is validated and appended in the same synchronous
740
+ // commit as the insert — there is no state where a session exists without
741
+ // its creation fact (D6).
742
+ const validated = validateSessionEventWrite({
743
+ actor: humanActor(operator),
744
+ type: 'session.created',
745
+ payload: input.title !== undefined ? { title: input.title } : {},
746
+ })
747
+ state.events.push({
748
+ session: id,
749
+ seq: 1,
750
+ ts,
751
+ actor: validated.actor,
752
+ type: validated.type,
753
+ payload: validated.payload,
754
+ } as SessionEvent)
755
+ this.sessions.set(id, state)
756
+ return this.sessionSnapshot(state)
757
+ }
758
+
759
+ async getSession(id: string): Promise<SessionRecord | null> {
760
+ const state = this.sessions.get(id)
761
+ return state ? this.sessionSnapshot(state) : null
762
+ }
763
+
764
+ async listSessions(repo: string): Promise<SessionRecord[]> {
765
+ // Pinned tiebreak (store/types.ts): Map iteration is insertion order and
766
+ // Map.set on an existing key preserves position, so insertion order *is*
767
+ // creation order — the store-assigned monotonic creation sequence for
768
+ // free, no counter needed. Sessions are never deleted, so ties never
769
+ // reorder over time.
770
+ return [...this.sessions.values()]
771
+ .filter((state) => state.record.repo === repo)
772
+ .map((state) => this.sessionSnapshot(state))
773
+ }
774
+
775
+ async appendSessionEvent<T extends SessionEventType>(
776
+ id: string,
777
+ event: SessionEventWrite<T>,
778
+ ): Promise<SessionEventEnvelope<T>> {
779
+ const state = this.sessionState(id)
780
+ const validated = validateSessionEventWrite(event)
781
+ const envelope = {
782
+ session: id,
783
+ seq: state.events.length + 1,
784
+ ts: this.now(),
785
+ actor: validated.actor,
786
+ type: validated.type,
787
+ payload: validated.payload,
788
+ } as SessionEventEnvelope<T>
789
+ state.events.push(structuredClone(envelope) as SessionEvent)
790
+ state.record.updatedAt = envelope.ts
791
+ return envelope
792
+ }
793
+
794
+ async getSessionEvents(
795
+ id: string,
796
+ sinceSeq = 0,
797
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
798
+ ): Promise<SessionEvent[]> {
799
+ const read = async (): Promise<SessionEvent[]> =>
800
+ structuredClone(this.sessionState(id).events.filter((event) => event.seq > sinceSeq))
801
+ return readEventsWithWait({
802
+ read,
803
+ waitSeconds: opts?.waitSeconds,
804
+ signal: opts?.signal,
805
+ // Hosted budget: held session-event reads poll at most once per second
806
+ // (AUT-383), matching the build and repository event reads — not the
807
+ // 25 ms stream default, which is presentation content's cadence.
808
+ pollMs: EVENT_WAIT_POLL_MS,
809
+ })
810
+ }
811
+
812
+ async appendSessionWithArtifacts<T extends SessionEventType>(
813
+ id: string,
814
+ artifacts: ArtifactInput[],
815
+ makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
816
+ ): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
817
+ const state = this.sessionState(id)
818
+ const prepared: {
819
+ kind: string
820
+ blobRef: string
821
+ metadata: Record<string, unknown>
822
+ }[] = []
823
+ for (const artifact of artifacts) {
824
+ if (!artifact.kind) throw new Error('artifact kind is required')
825
+ const bytes = toBytes(artifact.content)
826
+ const blobRef = contentHash(bytes)
827
+ await this.blobs.put(blobRef, bytes)
828
+ prepared.push({
829
+ kind: artifact.kind,
830
+ blobRef,
831
+ metadata: structuredClone(artifact.metadata ?? {}),
832
+ })
833
+ }
834
+ // Same shape as `appendRepoWithArtifacts`: everything is validated before
835
+ // the first mutation, so the synchronous commit has no rollback path to
836
+ // get wrong (D6) and the validation-before-prune invariant (AUT-322)
837
+ // holds trivially — session artifacts are not retention-managed.
838
+ const ts = this.now()
839
+ const nextRev = new Map<string, number>()
840
+ const deposited = prepared.map((item): SessionArtifactMeta => {
841
+ const revision =
842
+ nextRev.get(item.kind) ?? MemoryBuildStore.nextRevision(state.artifacts.get(item.kind))
843
+ nextRev.set(item.kind, revision + 1)
844
+ return {
845
+ session: id,
846
+ kind: item.kind,
847
+ revision,
848
+ blobRef: item.blobRef,
849
+ metadata: item.metadata,
850
+ createdAt: ts,
851
+ }
852
+ })
853
+ const validated = validateSessionEventWrite(makeEvent(structuredClone(deposited)))
854
+ for (const meta of deposited) {
855
+ const revisions = state.artifacts.get(meta.kind) ?? []
856
+ revisions.push(meta)
857
+ state.artifacts.set(meta.kind, revisions)
858
+ }
859
+ const envelope = {
860
+ session: id,
861
+ seq: state.events.length + 1,
862
+ ts,
863
+ actor: validated.actor,
864
+ type: validated.type,
865
+ payload: validated.payload,
866
+ } as SessionEventEnvelope<T>
867
+ state.events.push(structuredClone(envelope) as SessionEvent)
868
+ state.record.updatedAt = ts
869
+ return { event: envelope, artifacts: structuredClone(deposited) }
870
+ }
871
+
872
+ async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
873
+ const state = this.sessionState(id)
874
+ if (!artifact.kind) throw new Error('artifact kind is required')
875
+ const bytes = toBytes(artifact.content)
876
+ const blobRef = contentHash(bytes)
877
+ await this.blobs.put(blobRef, bytes)
878
+ const revisions = state.artifacts.get(artifact.kind) ?? []
879
+ const meta: SessionArtifactMeta = {
880
+ session: id,
881
+ kind: artifact.kind,
882
+ revision: MemoryBuildStore.nextRevision(revisions),
883
+ blobRef,
884
+ metadata: structuredClone(artifact.metadata ?? {}),
885
+ createdAt: this.now(),
886
+ }
887
+ revisions.push(meta)
888
+ state.artifacts.set(artifact.kind, revisions)
889
+ state.record.updatedAt = meta.createdAt
890
+ return structuredClone(meta)
891
+ }
892
+
893
+ async getSessionArtifact(
894
+ id: string,
895
+ kind: string,
896
+ rev?: number,
897
+ ): Promise<SessionArtifact | null> {
898
+ const revisions = this.sessionState(id).artifacts.get(kind)
899
+ if (!revisions || revisions.length === 0) return null
900
+ const meta =
901
+ rev === undefined
902
+ ? revisions.at(-1)
903
+ : revisions.find((candidate) => candidate.revision === rev)
904
+ if (!meta) return null
905
+ const content = await this.blobs.get(meta.blobRef)
906
+ return content ? { meta: structuredClone(meta), content } : null
907
+ }
908
+
909
+ async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
910
+ const all = [...this.sessionState(id).artifacts.values()].flat()
911
+ return structuredClone(
912
+ (kind ? all.filter((meta) => meta.kind === kind) : all).sort(
913
+ (a, b) => a.kind.localeCompare(b.kind) || a.revision - b.revision,
914
+ ),
915
+ )
916
+ }
917
+
918
+ // ── Streams (SPEC §7.6 — the third primitive) ───────────────────────────
919
+
920
+ private streamState(streamId: string): StreamState {
921
+ const state = this.streams.get(streamId)
922
+ if (!state) throw new Error(`unknown stream "${streamId}"`)
923
+ return state
924
+ }
925
+
926
+ private snapshotStream(state: StreamState): StreamRecord {
927
+ return structuredClone(state.record)
928
+ }
929
+
930
+ /** Stream-chunk retention (SPEC §7.6, deposit-path and count-based like
931
+ * artifact retention): at create, drop the chunks of every previously
932
+ * closed stream in the same scope except the most recently closed one.
933
+ * Records and finalized artifacts are never touched; open streams are
934
+ * never pruned. Runs in the same synchronous commit as the create. */
935
+ private pruneStreamChunksInCommit(scope: StreamScope): void {
936
+ const inScope = [...this.streams.values()].filter(
937
+ (state) =>
938
+ state.record.status === 'closed' &&
939
+ state.record.scope.kind === scope.kind &&
940
+ (scope.kind === 'build'
941
+ ? state.record.scope.kind === 'build' && state.record.scope.build === scope.build
942
+ : scope.kind === 'repo'
943
+ ? state.record.scope.kind === 'repo' && state.record.scope.repo === scope.repo
944
+ : state.record.scope.kind === 'session' &&
945
+ state.record.scope.session === scope.session),
946
+ )
947
+ if (inScope.length <= 1) return
948
+ // Most recently closed survives (order by closedAt, tie-break by id).
949
+ const keep = inScope
950
+ .map((state) => state.record)
951
+ .sort(
952
+ (a, b) => (a.closedAt ?? '').localeCompare(b.closedAt ?? '') || a.id.localeCompare(b.id),
953
+ )
954
+ .at(-1)!
955
+ for (const state of inScope) {
956
+ if (state.record.id !== keep.id) state.chunks = []
957
+ }
958
+ }
959
+
960
+ async createStream(scope: StreamScope, label: string): Promise<StreamRecord> {
961
+ if (!label) throw new Error('stream label is required')
962
+ if (scope.kind === 'build') this.state(scope.build)
963
+ else if (scope.kind === 'repo') this.repoState(scope.repo)
964
+ else this.sessionState(scope.session)
965
+ const id = `st_${crypto.randomUUID()}`
966
+ const record: StreamRecord = {
967
+ id,
968
+ scope: structuredClone(scope),
969
+ label,
970
+ format: STREAM_FORMAT,
971
+ status: 'open',
972
+ createdAt: this.now(),
973
+ }
974
+ // Synchronous commit: the retention prune and the insert are one step.
975
+ this.pruneStreamChunksInCommit(record.scope)
976
+ this.streams.set(id, { record, chunks: [] })
977
+ return this.snapshotStream(this.streamState(id))
978
+ }
979
+
980
+ async appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
981
+ // Validate and size-check before taking the per-stream lock, so invalid
982
+ // input keeps its current error precedence (AUT-348).
983
+ validateStreamParts(parts)
984
+ const bytes = serializedBatchSize(parts)
985
+ if (bytes > STREAM_BATCH_MAX_BYTES) throw new StreamBatchTooLargeError(bytes)
986
+ // The per-stream lock covers the closed check and the mutation as one
987
+ // critical section: an append issued during a close waits for the close
988
+ // to commit, then fails the status check with an explicit
989
+ // StreamClosedError instead of being silently omitted from the artifact.
990
+ return this.streamLocks.run(streamId, () => {
991
+ const state = this.streamState(streamId)
992
+ if (state.record.status === 'closed') throw new StreamClosedError(streamId)
993
+ const chunk: StreamChunk = {
994
+ stream: streamId,
995
+ seq: state.chunks.length + 1,
996
+ ts: this.now(),
997
+ parts: structuredClone(parts),
998
+ }
999
+ state.chunks.push(chunk)
1000
+ return structuredClone(chunk)
1001
+ })
1002
+ }
1003
+
1004
+ async readStream(
1005
+ streamId: string,
1006
+ opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
1007
+ ): Promise<StreamRead> {
1008
+ const read = async (): Promise<StreamRead> => {
1009
+ const state = this.streamState(streamId)
1010
+ const record = state.record
1011
+ return {
1012
+ chunks: structuredClone(state.chunks.filter((chunk) => chunk.seq > (opts?.since ?? 0))),
1013
+ status: record.status,
1014
+ ...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
1015
+ ...(record.artifact !== undefined ? { artifact: structuredClone(record.artifact) } : {}),
1016
+ }
1017
+ }
1018
+ return readStreamWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
1019
+ }
1020
+
1021
+ async closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
1022
+ // The per-stream lock covers prepare and commit as one critical section
1023
+ // (AUT-348): the commit is synchronous, so with the lock held, no in-
1024
+ // process append can land inside the prepare window — one that waits
1025
+ // instead rejects on the closed check below.
1026
+ return this.streamLocks.run(streamId, async () => {
1027
+ const state = this.streamState(streamId)
1028
+ if (state.record.status === 'closed') return this.snapshotStream(state)
1029
+ // Prepare phase — assemble and store the blob before touching stream
1030
+ // state (mirrors appendWithArtifacts: content-addressed orphans are
1031
+ // harmless; a deposit failure leaves the stream open and unwritten).
1032
+ const { document, droppedPartCount } = await assembleUIMessageDocument(
1033
+ structuredClone(state.chunks.flatMap((chunk) => chunk.parts)),
1034
+ )
1035
+ const ts = this.now()
1036
+ const record = structuredClone(state.record)
1037
+ const input = streamArtifactInput(
1038
+ record.id,
1039
+ record.scope,
1040
+ record.label,
1041
+ outcome,
1042
+ document,
1043
+ state.chunks.length,
1044
+ droppedPartCount,
1045
+ )
1046
+ const bytes = toBytes(input.content)
1047
+ const blobRef = contentHash(bytes)
1048
+ await this.blobs.put(blobRef, bytes)
1049
+ // Commit phase — fully synchronous (no await), so no interleaved writer
1050
+ // can slip between the artifact deposit and the close landing. A close
1051
+ // that raced us through the prepare phase wins; ours is the no-op.
1052
+ const closed: StreamRecord = {
1053
+ ...record,
1054
+ status: 'closed',
1055
+ closedAt: ts,
1056
+ outcome,
1057
+ artifact: { kind: input.kind, revision: 0, blobRef },
1058
+ }
1059
+ // A close that raced us through the prepare phase wins; ours is the no-op.
1060
+ const fresh = this.streams.get(streamId)
1061
+ if (fresh && fresh.record.status === 'closed') return this.snapshotStream(fresh)
1062
+ state.record = closed
1063
+ if (record.scope.kind === 'build') {
1064
+ const meta: ArtifactMeta = {
1065
+ build: record.scope.build,
1066
+ kind: input.kind,
1067
+ revision: 0,
1068
+ blobRef,
1069
+ metadata: structuredClone(input.metadata),
1070
+ createdAt: ts,
1071
+ }
1072
+ const buildState = this.state(record.scope.build)
1073
+ const revs = buildState.artifacts.get(input.kind) ?? []
1074
+ revs.push(meta)
1075
+ buildState.artifacts.set(input.kind, revs)
1076
+ } else if (record.scope.kind === 'repo') {
1077
+ const repoMeta: RepositoryArtifactMeta = {
1078
+ repo: record.scope.repo,
1079
+ kind: input.kind,
1080
+ revision: 0,
1081
+ blobRef,
1082
+ metadata: structuredClone(input.metadata),
1083
+ createdAt: ts,
1084
+ }
1085
+ const repoState = this.repoState(record.scope.repo)
1086
+ const revs = repoState.artifacts.get(input.kind) ?? []
1087
+ revs.push(repoMeta)
1088
+ repoState.artifacts.set(input.kind, revs)
1089
+ } else {
1090
+ const sessionMeta: SessionArtifactMeta = {
1091
+ session: record.scope.session,
1092
+ kind: input.kind,
1093
+ revision: 0,
1094
+ blobRef,
1095
+ metadata: structuredClone(input.metadata),
1096
+ createdAt: ts,
1097
+ }
1098
+ const sessionState = this.sessionState(record.scope.session)
1099
+ const revs = sessionState.artifacts.get(input.kind) ?? []
1100
+ revs.push(sessionMeta)
1101
+ sessionState.artifacts.set(input.kind, revs)
1102
+ }
1103
+ return this.snapshotStream(state)
1104
+ })
1105
+ }
1106
+
1107
+ async getStream(streamId: string): Promise<StreamRecord | null> {
1108
+ const state = this.streams.get(streamId)
1109
+ return state ? this.snapshotStream(state) : null
1110
+ }
1111
+
1112
+ async listStreams(scope: StreamScope): Promise<StreamRecord[]> {
1113
+ // A list of nothing is nothing: an unknown scope lists empty (only
1114
+ // createStream requires the scope's resource to exist).
1115
+ const records = [...this.streams.values()]
1116
+ .filter(
1117
+ (state) =>
1118
+ state.record.scope.kind === scope.kind &&
1119
+ (scope.kind === 'build'
1120
+ ? state.record.scope.kind === 'build' && state.record.scope.build === scope.build
1121
+ : scope.kind === 'repo'
1122
+ ? state.record.scope.kind === 'repo' && state.record.scope.repo === scope.repo
1123
+ : state.record.scope.kind === 'session' &&
1124
+ state.record.scope.session === scope.session),
1125
+ )
1126
+ .map((state) => this.snapshotStream(state))
1127
+ // Pinned tiebreak (store/types.ts): Map iteration is insertion order and
1128
+ // Map.set on an existing key preserves position, so insertion order *is*
1129
+ // creation order — the store-assigned monotonic creation sequence for
1130
+ // free, no counter needed. Streams are never deleted (retention prunes
1131
+ // stream_chunks only), so ties never reorder over time.
1132
+ return records
1133
+ }
1134
+
1135
+ async close(): Promise<void> {
1136
+ // Nothing to release.
1137
+ }
1138
+ }