@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,1713 @@
1
+ /**
2
+ * The local BuildStore (SPEC §7.2.1): SQLite + blob directory beneath an
3
+ * explicitly resolved local state root. Repository/default selection belongs
4
+ * to the CLI; this adapter only opens the path it is given.
5
+ *
6
+ * Every write goes through `validateEventWrite` (§8 — the enforced ontology).
7
+ * Seq assignment and `appendWithArtifacts` are transactional: bun:sqlite
8
+ * transactions are synchronous, so the atomic path (D6) runs inside one
9
+ * `db.transaction` while blob writes happen *before* it — a rolled-back
10
+ * deposit may orphan a blob, which is harmless because blobs are
11
+ * content-addressed.
12
+ */
13
+ import { Database } from 'bun:sqlite'
14
+ import { and, asc, desc, eq, gt, inArray, sql } from 'drizzle-orm'
15
+ import { drizzle, type BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'
16
+ import { mkdirSync } from 'node:fs'
17
+ import { access, copyFile, mkdtemp, rm } from 'node:fs/promises'
18
+ import { tmpdir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import { humanActor } from '../../events/envelope'
21
+ import {
22
+ validateEventWrite,
23
+ type AbEvent,
24
+ type EventEnvelope,
25
+ type EventWrite,
26
+ } from '../../events/catalog'
27
+ import type { EventType } from '../../events/payloads'
28
+ import {
29
+ validateRepositoryEventWrite,
30
+ type RepositoryEvent,
31
+ type RepositoryEventEnvelope,
32
+ type RepositoryEventType,
33
+ type RepositoryEventWrite,
34
+ } from '../../events/repository'
35
+ import {
36
+ validateSessionEventWrite,
37
+ type SessionEvent,
38
+ type SessionEventEnvelope,
39
+ type SessionEventType,
40
+ type SessionEventWrite,
41
+ } from '../../events/sessions'
42
+ import { createBuildScopedStore } from '../build-scope'
43
+ import { createSessionScopedStore } from '../session-handle'
44
+ import {
45
+ DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
46
+ isRetentionManagedKind,
47
+ revisionsToPrune,
48
+ } from '../retention'
49
+ import { pollingSubscribe } from '../subscribe'
50
+ import { StreamLocks } from '../streams/lock'
51
+ import { assembleUIMessageDocument } from '../streams/assemble'
52
+ import { readEventsWithWait, readStreamWithWait } from '../streams/wait'
53
+ import {
54
+ serializedBatchSize,
55
+ STREAM_BATCH_MAX_BYTES,
56
+ STREAM_FORMAT,
57
+ StreamBatchTooLargeError,
58
+ StreamClosedError,
59
+ streamArtifactInput,
60
+ validateStreamParts,
61
+ type StreamChunk,
62
+ type StreamOutcome,
63
+ type StreamPart,
64
+ type StreamRead,
65
+ type StreamRecord,
66
+ type StreamScope,
67
+ } from '../streams/types'
68
+ import {
69
+ contentHash,
70
+ normalizeOperator,
71
+ systemClock,
72
+ toBytes,
73
+ validateExpectedSeq,
74
+ type Artifact,
75
+ type ArtifactInput,
76
+ type ArtifactMeta,
77
+ type BlobStore,
78
+ type BuildRecord,
79
+ type BuildScopedStore,
80
+ type BuildStore,
81
+ type Clock,
82
+ type NewBuildInput,
83
+ type NewSessionInput,
84
+ type RepositoryArtifact,
85
+ type RepositoryArtifactMeta,
86
+ type RepositoryRecord,
87
+ type SessionArtifact,
88
+ type SessionArtifactMeta,
89
+ type SessionRecord,
90
+ type SessionScopedStore,
91
+ type SubscribeOptions,
92
+ type Unsubscribe,
93
+ } from '../types'
94
+ import { DirBlobStore } from './blobs'
95
+ import {
96
+ artifacts,
97
+ builds,
98
+ events,
99
+ repoArtifacts,
100
+ repoEvents,
101
+ repoStreams,
102
+ sessionArtifacts,
103
+ sessionEvents,
104
+ sessions,
105
+ streamChunks,
106
+ streams,
107
+ } from './schema'
108
+
109
+ /**
110
+ * Bootstrap DDL, applied idempotently at open. MUST match `schema.ts` —
111
+ * the drizzle schema is the source of truth; this is its inlined form so
112
+ * opening a store never needs a migration step.
113
+ */
114
+ const BOOTSTRAP_DDL = [
115
+ `CREATE TABLE IF NOT EXISTS builds (
116
+ slug TEXT PRIMARY KEY,
117
+ repo TEXT NOT NULL,
118
+ repo_origin TEXT,
119
+ ticket TEXT,
120
+ branch TEXT,
121
+ created_at TEXT NOT NULL,
122
+ updated_at TEXT NOT NULL,
123
+ lease_holder TEXT,
124
+ lease_expires_at TEXT,
125
+ lease_ttl_ms INTEGER,
126
+ heartbeat_at TEXT
127
+ )`,
128
+ `CREATE TABLE IF NOT EXISTS events (
129
+ build TEXT NOT NULL,
130
+ seq INTEGER NOT NULL,
131
+ ts TEXT NOT NULL,
132
+ actor TEXT NOT NULL,
133
+ type TEXT NOT NULL,
134
+ payload TEXT NOT NULL,
135
+ PRIMARY KEY (build, seq)
136
+ )`,
137
+ `CREATE TABLE IF NOT EXISTS artifacts (
138
+ build TEXT NOT NULL,
139
+ kind TEXT NOT NULL,
140
+ revision INTEGER NOT NULL,
141
+ blob_ref TEXT NOT NULL,
142
+ metadata TEXT NOT NULL,
143
+ created_at TEXT NOT NULL,
144
+ PRIMARY KEY (build, kind, revision)
145
+ )`,
146
+ `CREATE TABLE IF NOT EXISTS repo_streams (
147
+ repo TEXT PRIMARY KEY,
148
+ created_at TEXT NOT NULL,
149
+ updated_at TEXT NOT NULL,
150
+ lease_holder TEXT,
151
+ lease_expires_at TEXT,
152
+ lease_ttl_ms INTEGER,
153
+ heartbeat_at TEXT
154
+ )`,
155
+ `CREATE TABLE IF NOT EXISTS repo_events (
156
+ repo TEXT NOT NULL,
157
+ seq INTEGER NOT NULL,
158
+ ts TEXT NOT NULL,
159
+ actor TEXT NOT NULL,
160
+ type TEXT NOT NULL,
161
+ payload TEXT NOT NULL,
162
+ PRIMARY KEY (repo, seq)
163
+ )`,
164
+ `CREATE TABLE IF NOT EXISTS repo_artifacts (
165
+ repo TEXT NOT NULL,
166
+ kind TEXT NOT NULL,
167
+ revision INTEGER NOT NULL,
168
+ blob_ref TEXT NOT NULL,
169
+ metadata TEXT NOT NULL,
170
+ created_at TEXT NOT NULL,
171
+ PRIMARY KEY (repo, kind, revision)
172
+ )`,
173
+ `CREATE TABLE IF NOT EXISTS sessions (
174
+ id TEXT PRIMARY KEY,
175
+ repo TEXT NOT NULL,
176
+ operator TEXT NOT NULL,
177
+ title TEXT,
178
+ creation_seq INTEGER NOT NULL,
179
+ created_at TEXT NOT NULL,
180
+ updated_at TEXT NOT NULL
181
+ )`,
182
+ `CREATE TABLE IF NOT EXISTS session_events (
183
+ session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
184
+ seq INTEGER NOT NULL,
185
+ ts TEXT NOT NULL,
186
+ actor TEXT NOT NULL,
187
+ type TEXT NOT NULL,
188
+ payload TEXT NOT NULL,
189
+ PRIMARY KEY (session, seq)
190
+ )`,
191
+ `CREATE TABLE IF NOT EXISTS session_artifacts (
192
+ session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
193
+ kind TEXT NOT NULL,
194
+ revision INTEGER NOT NULL,
195
+ blob_ref TEXT NOT NULL,
196
+ metadata TEXT NOT NULL,
197
+ created_at TEXT NOT NULL,
198
+ PRIMARY KEY (session, kind, revision)
199
+ )`,
200
+ `CREATE TABLE IF NOT EXISTS streams (
201
+ id TEXT PRIMARY KEY,
202
+ scope_kind TEXT NOT NULL CHECK (scope_kind IN ('build','repo','session')),
203
+ build TEXT,
204
+ repo TEXT,
205
+ session TEXT,
206
+ label TEXT NOT NULL,
207
+ format TEXT NOT NULL,
208
+ status TEXT NOT NULL CHECK (status IN ('open','closed')),
209
+ outcome TEXT,
210
+ artifact_kind TEXT,
211
+ artifact_revision INTEGER,
212
+ artifact_blob_ref TEXT,
213
+ created_at TEXT NOT NULL,
214
+ closed_at TEXT,
215
+ creation_seq INTEGER NOT NULL,
216
+ CHECK (
217
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
218
+ OR
219
+ (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
220
+ OR
221
+ (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
222
+ )
223
+ )`,
224
+ `CREATE TABLE IF NOT EXISTS stream_chunks (
225
+ stream TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
226
+ seq INTEGER NOT NULL,
227
+ ts TEXT NOT NULL,
228
+ parts TEXT NOT NULL,
229
+ PRIMARY KEY (stream, seq)
230
+ )`,
231
+ ] as const
232
+
233
+ type BuildRow = typeof builds.$inferSelect
234
+ type RepoRow = typeof repoStreams.$inferSelect
235
+
236
+ interface PreparedArtifact {
237
+ kind: string
238
+ blobRef: string
239
+ metadata: Record<string, unknown>
240
+ }
241
+
242
+ export interface SqliteBuildStoreOptions {
243
+ database: Database
244
+ blobs: BlobStore
245
+ clock?: Clock
246
+ /** Artifact retention (store/retention.ts): how many newest revisions of
247
+ * each retention-managed dispatcher kind survive. Default 200. */
248
+ retention?: { maxRevisions?: number }
249
+ }
250
+
251
+ export class SqliteBuildStore implements BuildStore {
252
+ private readonly sqlite: Database
253
+ private readonly db: BunSQLiteDatabase
254
+ /** Per-stream in-process mutex (store/streams/lock.ts): serializes a
255
+ * stream's close against same-process appends (AUT-348). Cross-connection
256
+ * writers are arbitrated by the commit transaction's chunk re-verification. */
257
+ private readonly streamLocks = new StreamLocks()
258
+ private readonly clock: Clock
259
+ private readonly maxRevisions: number
260
+ readonly blobs: BlobStore
261
+
262
+ constructor(opts: SqliteBuildStoreOptions) {
263
+ this.sqlite = opts.database
264
+ this.blobs = opts.blobs
265
+ this.clock = opts.clock ?? systemClock
266
+ this.maxRevisions = opts.retention?.maxRevisions ?? DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS
267
+ // busy_timeout first so even the WAL switch below waits out a concurrent
268
+ // opener instead of failing fast; WAL so connections on the same file see
269
+ // each other's writes (§7.2.1). Cross-process write serialization comes
270
+ // from `writeTx` (BEGIN IMMEDIATE), not from this timeout alone.
271
+ this.sqlite.exec('PRAGMA busy_timeout = 5000')
272
+ this.sqlite.exec('PRAGMA journal_mode = WAL')
273
+ for (const ddl of BOOTSTRAP_DDL) this.sqlite.exec(ddl)
274
+ // Stores created before `repo_origin` existed keep working: add the column
275
+ // idempotently when a pre-existing table lacks it (the bootstrap DDL above
276
+ // covers fresh stores). No migration framework — one guarded ALTER at open.
277
+ const columns = this.sqlite.query("PRAGMA table_info('builds')").all() as Array<{
278
+ name: string
279
+ }>
280
+ if (!columns.some((column) => column.name === 'repo_origin')) {
281
+ this.sqlite.exec('ALTER TABLE builds ADD COLUMN repo_origin TEXT')
282
+ }
283
+ // Stores created before session-scoped streams existed keep working: add
284
+ // the streams.session column idempotently when a pre-existing table lacks
285
+ // it (the repo_origin precedent). A pre-existing local database also keeps
286
+ // its old `scope_kind` CHECK, so it cannot host session-scoped streams —
287
+ // unreachable in product terms because sessions are hosted-only and
288
+ // nothing local creates one; fresh stores (and every contract-suite
289
+ // database) get the widened DDL above. Rebuilding the table to widen a
290
+ // CHECK would risk local data for an unreachable path and is deliberately
291
+ // not attempted.
292
+ const streamColumns = this.sqlite.query("PRAGMA table_info('streams')").all() as Array<{
293
+ name: string
294
+ }>
295
+ if (!streamColumns.some((column) => column.name === 'session')) {
296
+ this.sqlite.exec('ALTER TABLE streams ADD COLUMN session TEXT')
297
+ }
298
+ // Stores created before the listStreams creation-order tiebreak existed
299
+ // keep working: add the streams.creation_seq column idempotently when a
300
+ // pre-existing table lacks it (the sessions.creation_seq precedent), then
301
+ // backfill by rowid — rowid equals insertion order because streams are
302
+ // never deleted (retention prunes stream_chunks only), so the backfill is
303
+ // consistent with the pinned creation-order contract. Legacy
304
+ // same-millisecond ties are genuinely unorderable; the pinned guarantee
305
+ // applies from the migrated store onward.
306
+ if (!streamColumns.some((column) => column.name === 'creation_seq')) {
307
+ this.sqlite.exec('ALTER TABLE streams ADD COLUMN creation_seq INTEGER NOT NULL DEFAULT 0')
308
+ this.sqlite.exec('UPDATE streams SET creation_seq = rowid')
309
+ }
310
+ // Stores created before the listSessions creation-order tiebreak existed
311
+ // keep working: add the sessions.creation_seq column idempotently when a
312
+ // pre-existing table lacks it (the repo_origin precedent), then backfill
313
+ // by rowid — rowid equals insertion order because sessions are never
314
+ // deleted, so the backfill is consistent with the pinned creation-order
315
+ // contract. Legacy same-millisecond ties are genuinely unorderable; the
316
+ // pinned guarantee applies from the migrated store onward.
317
+ const sessionColumns = this.sqlite.query("PRAGMA table_info('sessions')").all() as Array<{
318
+ name: string
319
+ }>
320
+ if (!sessionColumns.some((column) => column.name === 'creation_seq')) {
321
+ this.sqlite.exec('ALTER TABLE sessions ADD COLUMN creation_seq INTEGER NOT NULL DEFAULT 0')
322
+ this.sqlite.exec('UPDATE sessions SET creation_seq = rowid')
323
+ }
324
+ this.db = drizzle(this.sqlite)
325
+ }
326
+
327
+ scopeBuild(slug: string): BuildScopedStore {
328
+ return createBuildScopedStore(this, slug)
329
+ }
330
+
331
+ scopeSession(id: string): SessionScopedStore {
332
+ return createSessionScopedStore(this, id)
333
+ }
334
+
335
+ private now(): string {
336
+ return this.clock().toISOString()
337
+ }
338
+
339
+ /**
340
+ * Every write runs BEGIN IMMEDIATE. The store is the only coordination
341
+ * surface (§15.2.7 [D2]), so cross-process writers are the norm: the
342
+ * dispatcher, per-build runners, and the agent's `ab` CLI all open this
343
+ * file (§3.3). A deferred transaction opens on a WAL read snapshot; when
344
+ * another process commits first, the read-to-write upgrade fails with
345
+ * SQLITE_BUSY *without consulting the busy handler*, losing the write.
346
+ * Taking the write lock at BEGIN makes contending writers queue on
347
+ * `busy_timeout` instead.
348
+ */
349
+ private writeTx<T>(fn: () => T): T {
350
+ return this.db.transaction(fn, { behavior: 'immediate' })
351
+ }
352
+
353
+ private buildRow(slug: string): BuildRow | undefined {
354
+ return this.db.select().from(builds).where(eq(builds.slug, slug)).get()
355
+ }
356
+
357
+ private requireBuild(slug: string): BuildRow {
358
+ const row = this.buildRow(slug)
359
+ if (!row) throw new Error(`unknown build "${slug}"`)
360
+ return row
361
+ }
362
+
363
+ private repoRow(repo: string): RepoRow | undefined {
364
+ return this.db.select().from(repoStreams).where(eq(repoStreams.repo, repo)).get()
365
+ }
366
+
367
+ private requireRepo(repo: string): RepoRow {
368
+ const row = this.repoRow(repo)
369
+ if (!row) throw new Error(`unknown repo "${repo}"`)
370
+ return row
371
+ }
372
+
373
+ private toRepoRecord(row: RepoRow): RepositoryRecord {
374
+ return {
375
+ repo: row.repo,
376
+ createdAt: row.createdAt,
377
+ updatedAt: row.updatedAt,
378
+ ...(row.heartbeatAt ? { heartbeatAt: row.heartbeatAt } : {}),
379
+ ...(row.leaseHolder && row.leaseExpiresAt
380
+ ? { lease: { holder: row.leaseHolder, expiresAt: row.leaseExpiresAt } }
381
+ : {}),
382
+ }
383
+ }
384
+
385
+ private toRecord(row: BuildRow): BuildRecord {
386
+ return {
387
+ slug: row.slug,
388
+ repo: row.repo,
389
+ ...(row.ticket ? { ticket: row.ticket } : {}),
390
+ ...(row.branch ? { branch: row.branch } : {}),
391
+ ...(row.repoOrigin ? { repoOrigin: row.repoOrigin } : {}),
392
+ createdAt: row.createdAt,
393
+ updatedAt: row.updatedAt,
394
+ ...(row.heartbeatAt ? { heartbeatAt: row.heartbeatAt } : {}),
395
+ ...(row.leaseHolder && row.leaseExpiresAt
396
+ ? { lease: { holder: row.leaseHolder, expiresAt: row.leaseExpiresAt } }
397
+ : {}),
398
+ }
399
+ }
400
+
401
+ async createBuild(input: NewBuildInput): Promise<BuildRecord> {
402
+ const ts = this.now()
403
+ return this.writeTx(() => {
404
+ if (this.buildRow(input.slug)) {
405
+ throw new Error(`build "${input.slug}" already exists`)
406
+ }
407
+ this.db
408
+ .insert(builds)
409
+ .values({
410
+ slug: input.slug,
411
+ repo: input.repo,
412
+ repoOrigin: input.repoOrigin ?? null,
413
+ ticket: input.ticket ?? null,
414
+ branch: input.branch ?? null,
415
+ createdAt: ts,
416
+ updatedAt: ts,
417
+ })
418
+ .run()
419
+ return this.toRecord(this.requireBuild(input.slug))
420
+ })
421
+ }
422
+
423
+ async getBuild(slug: string): Promise<BuildRecord | null> {
424
+ const row = this.buildRow(slug)
425
+ return row ? this.toRecord(row) : null
426
+ }
427
+
428
+ async listBuilds(): Promise<BuildRecord[]> {
429
+ const rows = this.db.select().from(builds).orderBy(asc(builds.createdAt)).all()
430
+ return rows.map((row) => this.toRecord(row))
431
+ }
432
+
433
+ /** Current build-stream tail inside an open transaction. */
434
+ private currentSeqInTx(slug: string): number {
435
+ this.requireBuild(slug)
436
+ const row = this.db
437
+ .select({ max: sql<number | null>`max(${events.seq})` })
438
+ .from(events)
439
+ .where(eq(events.build, slug))
440
+ .get()
441
+ return row?.max ?? 0
442
+ }
443
+
444
+ /**
445
+ * Runs inside an open transaction. bun:sqlite is a single synchronous
446
+ * connection, so statements issued through `this.db` inside a
447
+ * `db.transaction` callback join that transaction.
448
+ */
449
+ private appendInTx(slug: string, validated: EventWrite): EventEnvelope {
450
+ const seq = this.currentSeqInTx(slug) + 1
451
+ const ts = this.now()
452
+ this.db
453
+ .insert(events)
454
+ .values({
455
+ build: slug,
456
+ seq,
457
+ ts,
458
+ actor: validated.actor,
459
+ type: validated.type,
460
+ payload: validated.payload,
461
+ })
462
+ .run()
463
+ this.db.update(builds).set({ updatedAt: ts }).where(eq(builds.slug, slug)).run()
464
+ return {
465
+ build: slug,
466
+ seq,
467
+ ts,
468
+ actor: validated.actor,
469
+ type: validated.type,
470
+ payload: validated.payload,
471
+ }
472
+ }
473
+
474
+ async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
475
+ const validated = validateEventWrite(event)
476
+ return this.writeTx(() => this.appendInTx(slug, validated)) as EventEnvelope<T>
477
+ }
478
+
479
+ async appendIfCurrent<T extends EventType>(
480
+ slug: string,
481
+ expectedSeq: number,
482
+ event: EventWrite<T>,
483
+ ): Promise<EventEnvelope<T> | null> {
484
+ validateExpectedSeq(expectedSeq)
485
+ const validated = validateEventWrite(event)
486
+ return this.writeTx(() => {
487
+ // BEGIN IMMEDIATE serializes this comparison and append across every
488
+ // SQLite connection/process using the same store file.
489
+ if (this.currentSeqInTx(slug) !== expectedSeq) return null
490
+ return this.appendInTx(slug, validated) as EventEnvelope<T>
491
+ })
492
+ }
493
+
494
+ /** Hash + blob write happen before any transaction (D6). */
495
+ private async prepareArtifact(artifact: ArtifactInput): Promise<PreparedArtifact> {
496
+ if (!artifact.kind) throw new Error('artifact kind is required')
497
+ const bytes = toBytes(artifact.content)
498
+ const blobRef = contentHash(bytes)
499
+ await this.blobs.put(blobRef, bytes)
500
+ return {
501
+ kind: artifact.kind,
502
+ blobRef,
503
+ metadata: structuredClone(artifact.metadata ?? {}),
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Runs inside an open transaction — see `appendInTx`.
509
+ *
510
+ * `prune: false` defers deposit-time retention to the batch caller (the
511
+ * atomic `appendWithArtifacts` path), which prunes once per distinct kind
512
+ * *after* the batch event is validated — see the invariant documented on
513
+ * `appendWithArtifacts`. Single-deposit paths keep the default
514
+ * `prune: true` (deposit and prune in one step; no event is appended, so
515
+ * the validation-before-prune invariant does not apply).
516
+ */
517
+ private depositInTx(
518
+ slug: string,
519
+ prepared: PreparedArtifact,
520
+ opts: { prune?: boolean } = { prune: true },
521
+ ): ArtifactMeta {
522
+ this.requireBuild(slug)
523
+ const createdAt = this.now()
524
+ const row = this.db
525
+ .select({ max: sql<number | null>`max(${artifacts.revision})` })
526
+ .from(artifacts)
527
+ .where(and(eq(artifacts.build, slug), eq(artifacts.kind, prepared.kind)))
528
+ .get()
529
+ const revision = (row?.max ?? -1) + 1
530
+ this.db
531
+ .insert(artifacts)
532
+ .values({
533
+ build: slug,
534
+ kind: prepared.kind,
535
+ revision,
536
+ blobRef: prepared.blobRef,
537
+ metadata: prepared.metadata,
538
+ createdAt,
539
+ })
540
+ .run()
541
+ if (opts.prune) this.pruneBuildInTx(slug, prepared.kind)
542
+ this.db.update(builds).set({ updatedAt: createdAt }).where(eq(builds.slug, slug)).run()
543
+ return {
544
+ build: slug,
545
+ kind: prepared.kind,
546
+ revision,
547
+ blobRef: prepared.blobRef,
548
+ metadata: prepared.metadata,
549
+ createdAt,
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Deposit-time retention (store/retention.ts): inside the same transaction
555
+ * as the deposit, drop revisions past the bound for retention-managed
556
+ * kinds. Revision assignment reads MAX(revision)+1, so pruning never
557
+ * collides with it. Non-family kinds are never touched.
558
+ */
559
+ private pruneBuildInTx(slug: string, kind: string): void {
560
+ if (!isRetentionManagedKind(kind)) return
561
+ const rows = this.db
562
+ .select({ revision: artifacts.revision })
563
+ .from(artifacts)
564
+ .where(and(eq(artifacts.build, slug), eq(artifacts.kind, kind)))
565
+ .all()
566
+ const pruned = revisionsToPrune(
567
+ rows.map((row) => row.revision),
568
+ this.maxRevisions,
569
+ )
570
+ if (pruned.length === 0) return
571
+ this.db
572
+ .delete(artifacts)
573
+ .where(
574
+ and(
575
+ eq(artifacts.build, slug),
576
+ eq(artifacts.kind, kind),
577
+ inArray(artifacts.revision, pruned),
578
+ ),
579
+ )
580
+ .run()
581
+ }
582
+
583
+ private pruneRepoInTx(repo: string, kind: string): void {
584
+ if (!isRetentionManagedKind(kind)) return
585
+ const rows = this.db
586
+ .select({ revision: repoArtifacts.revision })
587
+ .from(repoArtifacts)
588
+ .where(and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, kind)))
589
+ .all()
590
+ const pruned = revisionsToPrune(
591
+ rows.map((row) => row.revision),
592
+ this.maxRevisions,
593
+ )
594
+ if (pruned.length === 0) return
595
+ this.db
596
+ .delete(repoArtifacts)
597
+ .where(
598
+ and(
599
+ eq(repoArtifacts.repo, repo),
600
+ eq(repoArtifacts.kind, kind),
601
+ inArray(repoArtifacts.revision, pruned),
602
+ ),
603
+ )
604
+ .run()
605
+ }
606
+
607
+ async appendWithArtifacts<T extends EventType>(
608
+ slug: string,
609
+ artifactInputs: ArtifactInput[],
610
+ makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
611
+ ): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
612
+ const prepared: PreparedArtifact[] = []
613
+ for (const input of artifactInputs) {
614
+ prepared.push(await this.prepareArtifact(input))
615
+ }
616
+ // One synchronous transaction: deposits + event append commit together;
617
+ // an invalid event throws, rolling back every deposit (D6).
618
+ //
619
+ // Ordering invariant (AUT-322): the batch event is validated BEFORE any
620
+ // retention prune runs. Deposits land unpruned, validation gates the
621
+ // whole batch, and only then does one prune per distinct batch kind
622
+ // execute — still inside this transaction. A same-kind batch whose prune
623
+ // scope covers a sibling therefore never deletes that sibling before the
624
+ // batch's event is validated. Pruning once per kind (not per deposit) is
625
+ // equivalent: `revisionsToPrune` is a pure function of the full
626
+ // post-batch revision set, and pruned revisions are always older than
627
+ // the current MAX, so revision assignment is unaffected.
628
+ return this.writeTx(() => {
629
+ const deposited = prepared.map((p) => this.depositInTx(slug, p, { prune: false }))
630
+ const validated = validateEventWrite(makeEvent(deposited))
631
+ for (const kind of new Set(deposited.map((meta) => meta.kind))) {
632
+ this.pruneBuildInTx(slug, kind)
633
+ }
634
+ const event = this.appendInTx(slug, validated) as EventEnvelope<T>
635
+ return { event, artifacts: deposited }
636
+ })
637
+ }
638
+
639
+ async getEvents(
640
+ slug: string,
641
+ sinceSeq = 0,
642
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
643
+ ): Promise<AbEvent[]> {
644
+ return readEventsWithWait({
645
+ read: async () => {
646
+ this.requireBuild(slug)
647
+ const rows = this.db
648
+ .select()
649
+ .from(events)
650
+ .where(and(eq(events.build, slug), gt(events.seq, sinceSeq)))
651
+ .orderBy(asc(events.seq))
652
+ .all()
653
+ return rows.map(
654
+ (row) =>
655
+ ({
656
+ build: row.build,
657
+ seq: row.seq,
658
+ ts: row.ts,
659
+ actor: row.actor,
660
+ type: row.type,
661
+ payload: row.payload,
662
+ }) as AbEvent,
663
+ )
664
+ },
665
+ waitSeconds: opts?.waitSeconds,
666
+ signal: opts?.signal,
667
+ })
668
+ }
669
+
670
+ async putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
671
+ const prepared = await this.prepareArtifact(artifact)
672
+ return this.writeTx(() => this.depositInTx(slug, prepared))
673
+ }
674
+
675
+ private toMeta(row: typeof artifacts.$inferSelect): ArtifactMeta {
676
+ return {
677
+ build: row.build,
678
+ kind: row.kind,
679
+ revision: row.revision,
680
+ blobRef: row.blobRef,
681
+ metadata: row.metadata,
682
+ createdAt: row.createdAt,
683
+ }
684
+ }
685
+
686
+ async getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
687
+ this.requireBuild(slug)
688
+ const scoped = and(eq(artifacts.build, slug), eq(artifacts.kind, kind))
689
+ const row =
690
+ rev === undefined
691
+ ? this.db
692
+ .select()
693
+ .from(artifacts)
694
+ .where(scoped)
695
+ .orderBy(desc(artifacts.revision))
696
+ .limit(1)
697
+ .get()
698
+ : this.db
699
+ .select()
700
+ .from(artifacts)
701
+ .where(and(scoped, eq(artifacts.revision, rev)))
702
+ .get()
703
+ if (!row) return null
704
+ const content = await this.blobs.get(row.blobRef)
705
+ if (!content) return null
706
+ return { meta: this.toMeta(row), content }
707
+ }
708
+
709
+ async listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
710
+ this.requireBuild(slug)
711
+ const where = kind
712
+ ? and(eq(artifacts.build, slug), eq(artifacts.kind, kind))
713
+ : eq(artifacts.build, slug)
714
+ const rows = this.db
715
+ .select()
716
+ .from(artifacts)
717
+ .where(where)
718
+ .orderBy(asc(artifacts.kind), asc(artifacts.revision))
719
+ .all()
720
+ return rows.map((row) => this.toMeta(row))
721
+ }
722
+
723
+ async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
724
+ return this.writeTx(() => {
725
+ const row = this.requireBuild(slug)
726
+ const now = this.clock().getTime()
727
+ const heldByOther =
728
+ row.leaseHolder !== null &&
729
+ row.leaseHolder !== holder &&
730
+ row.leaseExpiresAt !== null &&
731
+ Date.parse(row.leaseExpiresAt) > now
732
+ if (heldByOther) return false
733
+ this.db
734
+ .update(builds)
735
+ .set({
736
+ leaseHolder: holder,
737
+ leaseExpiresAt: new Date(now + ttlMs).toISOString(),
738
+ leaseTtlMs: ttlMs,
739
+ updatedAt: new Date(now).toISOString(),
740
+ })
741
+ .where(eq(builds.slug, slug))
742
+ .run()
743
+ return true
744
+ })
745
+ }
746
+
747
+ async heartbeat(slug: string, holder: string): Promise<boolean> {
748
+ return this.writeTx(() => {
749
+ const row = this.requireBuild(slug)
750
+ const now = this.clock().getTime()
751
+ const holds =
752
+ row.leaseHolder === holder &&
753
+ row.leaseExpiresAt !== null &&
754
+ Date.parse(row.leaseExpiresAt) > now
755
+ if (!holds) return false
756
+ const nowIso = new Date(now).toISOString()
757
+ this.db
758
+ .update(builds)
759
+ .set({
760
+ leaseExpiresAt: new Date(now + (row.leaseTtlMs ?? 0)).toISOString(),
761
+ heartbeatAt: nowIso,
762
+ updatedAt: nowIso,
763
+ })
764
+ .where(eq(builds.slug, slug))
765
+ .run()
766
+ return true
767
+ })
768
+ }
769
+
770
+ async releaseLease(slug: string, holder: string): Promise<void> {
771
+ this.writeTx(() => {
772
+ const row = this.requireBuild(slug)
773
+ if (row.leaseHolder !== holder) return
774
+ this.db
775
+ .update(builds)
776
+ .set({
777
+ leaseHolder: null,
778
+ leaseExpiresAt: null,
779
+ leaseTtlMs: null,
780
+ updatedAt: this.now(),
781
+ })
782
+ .where(eq(builds.slug, slug))
783
+ .run()
784
+ })
785
+ }
786
+
787
+ async ensureRepo(repo: string): Promise<RepositoryRecord> {
788
+ if (!repo) throw new Error('repo is required')
789
+ return this.writeTx(() => {
790
+ const existing = this.repoRow(repo)
791
+ if (existing) return this.toRepoRecord(existing)
792
+ const ts = this.now()
793
+ this.db.insert(repoStreams).values({ repo, createdAt: ts, updatedAt: ts }).run()
794
+ return this.toRepoRecord(this.requireRepo(repo))
795
+ })
796
+ }
797
+
798
+ async getRepo(repo: string): Promise<RepositoryRecord | null> {
799
+ const row = this.repoRow(repo)
800
+ return row ? this.toRepoRecord(row) : null
801
+ }
802
+
803
+ private appendRepoInTx(repo: string, validated: RepositoryEventWrite): RepositoryEventEnvelope {
804
+ this.requireRepo(repo)
805
+ const ts = this.now()
806
+ const row = this.db
807
+ .select({ max: sql<number | null>`max(${repoEvents.seq})` })
808
+ .from(repoEvents)
809
+ .where(eq(repoEvents.repo, repo))
810
+ .get()
811
+ const seq = (row?.max ?? 0) + 1
812
+ this.db
813
+ .insert(repoEvents)
814
+ .values({
815
+ repo,
816
+ seq,
817
+ ts,
818
+ actor: validated.actor,
819
+ type: validated.type,
820
+ payload: validated.payload,
821
+ })
822
+ .run()
823
+ this.db.update(repoStreams).set({ updatedAt: ts }).where(eq(repoStreams.repo, repo)).run()
824
+ return {
825
+ repo,
826
+ seq,
827
+ ts,
828
+ actor: validated.actor,
829
+ type: validated.type,
830
+ payload: validated.payload,
831
+ }
832
+ }
833
+
834
+ async appendRepo<T extends RepositoryEventType>(
835
+ repo: string,
836
+ event: RepositoryEventWrite<T>,
837
+ ): Promise<RepositoryEventEnvelope<T>> {
838
+ const validated = validateRepositoryEventWrite(event)
839
+ return this.writeTx(() => this.appendRepoInTx(repo, validated)) as RepositoryEventEnvelope<T>
840
+ }
841
+
842
+ /** See `depositInTx` for the `prune` option's meaning. */
843
+ private depositRepoInTx(
844
+ repo: string,
845
+ prepared: PreparedArtifact,
846
+ opts: { prune?: boolean } = { prune: true },
847
+ ): RepositoryArtifactMeta {
848
+ this.requireRepo(repo)
849
+ const createdAt = this.now()
850
+ const row = this.db
851
+ .select({ max: sql<number | null>`max(${repoArtifacts.revision})` })
852
+ .from(repoArtifacts)
853
+ .where(and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, prepared.kind)))
854
+ .get()
855
+ const revision = (row?.max ?? -1) + 1
856
+ this.db
857
+ .insert(repoArtifacts)
858
+ .values({
859
+ repo,
860
+ kind: prepared.kind,
861
+ revision,
862
+ blobRef: prepared.blobRef,
863
+ metadata: prepared.metadata,
864
+ createdAt,
865
+ })
866
+ .run()
867
+ if (opts.prune) this.pruneRepoInTx(repo, prepared.kind)
868
+ this.db
869
+ .update(repoStreams)
870
+ .set({ updatedAt: createdAt })
871
+ .where(eq(repoStreams.repo, repo))
872
+ .run()
873
+ return {
874
+ repo,
875
+ kind: prepared.kind,
876
+ revision,
877
+ blobRef: prepared.blobRef,
878
+ metadata: prepared.metadata,
879
+ createdAt,
880
+ }
881
+ }
882
+
883
+ async appendRepoWithArtifacts<T extends RepositoryEventType>(
884
+ repo: string,
885
+ artifactInputs: ArtifactInput[],
886
+ makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
887
+ ): Promise<{
888
+ event: RepositoryEventEnvelope<T>
889
+ artifacts: RepositoryArtifactMeta[]
890
+ }> {
891
+ const prepared: PreparedArtifact[] = []
892
+ for (const input of artifactInputs) {
893
+ prepared.push(await this.prepareArtifact(input))
894
+ }
895
+ // Ordering invariant (AUT-322): same shape as `appendWithArtifacts` —
896
+ // deposit unpruned, validate the batch event, then prune once per
897
+ // distinct batch kind, all inside this transaction. Validation
898
+ // provably precedes any retention deletion.
899
+ return this.writeTx(() => {
900
+ const deposited = prepared.map((item) => this.depositRepoInTx(repo, item, { prune: false }))
901
+ const validated = validateRepositoryEventWrite(makeEvent(deposited))
902
+ for (const kind of new Set(deposited.map((meta) => meta.kind))) {
903
+ this.pruneRepoInTx(repo, kind)
904
+ }
905
+ const event = this.appendRepoInTx(repo, validated) as RepositoryEventEnvelope<T>
906
+ return { event, artifacts: deposited }
907
+ })
908
+ }
909
+
910
+ async getRepoEvents(
911
+ repo: string,
912
+ sinceSeq = 0,
913
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
914
+ ): Promise<RepositoryEvent[]> {
915
+ return readEventsWithWait({
916
+ read: async () => {
917
+ this.requireRepo(repo)
918
+ const rows = this.db
919
+ .select()
920
+ .from(repoEvents)
921
+ .where(and(eq(repoEvents.repo, repo), gt(repoEvents.seq, sinceSeq)))
922
+ .orderBy(asc(repoEvents.seq))
923
+ .all()
924
+ return rows.map(
925
+ (row) =>
926
+ ({
927
+ repo: row.repo,
928
+ seq: row.seq,
929
+ ts: row.ts,
930
+ actor: row.actor,
931
+ type: row.type,
932
+ payload: row.payload,
933
+ }) as RepositoryEvent,
934
+ )
935
+ },
936
+ waitSeconds: opts?.waitSeconds,
937
+ signal: opts?.signal,
938
+ })
939
+ }
940
+
941
+ async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
942
+ const prepared = await this.prepareArtifact(artifact)
943
+ return this.writeTx(() => this.depositRepoInTx(repo, prepared))
944
+ }
945
+
946
+ private toRepoMeta(row: typeof repoArtifacts.$inferSelect): RepositoryArtifactMeta {
947
+ return {
948
+ repo: row.repo,
949
+ kind: row.kind,
950
+ revision: row.revision,
951
+ blobRef: row.blobRef,
952
+ metadata: row.metadata,
953
+ createdAt: row.createdAt,
954
+ }
955
+ }
956
+
957
+ async getRepoArtifact(
958
+ repo: string,
959
+ kind: string,
960
+ rev?: number,
961
+ ): Promise<RepositoryArtifact | null> {
962
+ this.requireRepo(repo)
963
+ const scoped = and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, kind))
964
+ const row =
965
+ rev === undefined
966
+ ? this.db
967
+ .select()
968
+ .from(repoArtifacts)
969
+ .where(scoped)
970
+ .orderBy(desc(repoArtifacts.revision))
971
+ .limit(1)
972
+ .get()
973
+ : this.db
974
+ .select()
975
+ .from(repoArtifacts)
976
+ .where(and(scoped, eq(repoArtifacts.revision, rev)))
977
+ .get()
978
+ if (!row) return null
979
+ const content = await this.blobs.get(row.blobRef)
980
+ return content ? { meta: this.toRepoMeta(row), content } : null
981
+ }
982
+
983
+ async listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
984
+ this.requireRepo(repo)
985
+ const where = kind
986
+ ? and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, kind))
987
+ : eq(repoArtifacts.repo, repo)
988
+ return this.db
989
+ .select()
990
+ .from(repoArtifacts)
991
+ .where(where)
992
+ .orderBy(asc(repoArtifacts.kind), asc(repoArtifacts.revision))
993
+ .all()
994
+ .map((row) => this.toRepoMeta(row))
995
+ }
996
+
997
+ async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
998
+ return this.writeTx(() => {
999
+ const row = this.requireRepo(repo)
1000
+ const now = this.clock().getTime()
1001
+ const heldByOther =
1002
+ row.leaseHolder !== null &&
1003
+ row.leaseHolder !== holder &&
1004
+ row.leaseExpiresAt !== null &&
1005
+ Date.parse(row.leaseExpiresAt) > now
1006
+ if (heldByOther) return false
1007
+ this.db
1008
+ .update(repoStreams)
1009
+ .set({
1010
+ leaseHolder: holder,
1011
+ leaseExpiresAt: new Date(now + ttlMs).toISOString(),
1012
+ leaseTtlMs: ttlMs,
1013
+ updatedAt: new Date(now).toISOString(),
1014
+ })
1015
+ .where(eq(repoStreams.repo, repo))
1016
+ .run()
1017
+ return true
1018
+ })
1019
+ }
1020
+
1021
+ async heartbeatRepo(repo: string, holder: string): Promise<boolean> {
1022
+ return this.writeTx(() => {
1023
+ const row = this.requireRepo(repo)
1024
+ const now = this.clock().getTime()
1025
+ const holds =
1026
+ row.leaseHolder === holder &&
1027
+ row.leaseExpiresAt !== null &&
1028
+ Date.parse(row.leaseExpiresAt) > now
1029
+ if (!holds) return false
1030
+ const nowIso = new Date(now).toISOString()
1031
+ this.db
1032
+ .update(repoStreams)
1033
+ .set({
1034
+ leaseExpiresAt: new Date(now + (row.leaseTtlMs ?? 0)).toISOString(),
1035
+ heartbeatAt: nowIso,
1036
+ updatedAt: nowIso,
1037
+ })
1038
+ .where(eq(repoStreams.repo, repo))
1039
+ .run()
1040
+ return true
1041
+ })
1042
+ }
1043
+
1044
+ async releaseRepoLease(repo: string, holder: string): Promise<void> {
1045
+ this.writeTx(() => {
1046
+ const row = this.requireRepo(repo)
1047
+ if (row.leaseHolder !== holder) return
1048
+ this.db
1049
+ .update(repoStreams)
1050
+ .set({
1051
+ leaseHolder: null,
1052
+ leaseExpiresAt: null,
1053
+ leaseTtlMs: null,
1054
+ updatedAt: this.now(),
1055
+ })
1056
+ .where(eq(repoStreams.repo, repo))
1057
+ .run()
1058
+ })
1059
+ }
1060
+
1061
+ // ── Operator sessions (SPEC §7.1.1 — a third resource kind) ─────────
1062
+
1063
+ private sessionRow(id: string): typeof sessions.$inferSelect | undefined {
1064
+ return this.db.select().from(sessions).where(eq(sessions.id, id)).get()
1065
+ }
1066
+
1067
+ private requireSession(id: string): typeof sessions.$inferSelect {
1068
+ const row = this.sessionRow(id)
1069
+ if (!row) throw new Error(`unknown session "${id}"`)
1070
+ return row
1071
+ }
1072
+
1073
+ private toSessionRecord(row: typeof sessions.$inferSelect): SessionRecord {
1074
+ return {
1075
+ id: row.id,
1076
+ repo: row.repo,
1077
+ operator: row.operator,
1078
+ ...(row.title !== null ? { title: row.title } : {}),
1079
+ createdAt: row.createdAt,
1080
+ updatedAt: row.updatedAt,
1081
+ }
1082
+ }
1083
+
1084
+ /** Runs inside an open transaction — see `appendInTx`. */
1085
+ private appendSessionInTx(id: string, validated: SessionEventWrite): SessionEventEnvelope {
1086
+ this.requireSession(id)
1087
+ const ts = this.now()
1088
+ const row = this.db
1089
+ .select({ max: sql<number | null>`max(${sessionEvents.seq})` })
1090
+ .from(sessionEvents)
1091
+ .where(eq(sessionEvents.session, id))
1092
+ .get()
1093
+ const seq = (row?.max ?? 0) + 1
1094
+ this.db
1095
+ .insert(sessionEvents)
1096
+ .values({
1097
+ session: id,
1098
+ seq,
1099
+ ts,
1100
+ actor: validated.actor,
1101
+ type: validated.type,
1102
+ payload: validated.payload,
1103
+ })
1104
+ .run()
1105
+ this.db.update(sessions).set({ updatedAt: ts }).where(eq(sessions.id, id)).run()
1106
+ return {
1107
+ session: id,
1108
+ seq,
1109
+ ts,
1110
+ actor: validated.actor,
1111
+ type: validated.type,
1112
+ payload: validated.payload,
1113
+ }
1114
+ }
1115
+
1116
+ async createSession(input: NewSessionInput): Promise<SessionRecord> {
1117
+ const operator = normalizeOperator(input.operator)
1118
+ if (!input.repo) throw new Error('repo is required')
1119
+ const ts = this.now()
1120
+ const id = `os_${crypto.randomUUID()}`
1121
+ // The record and its first fact land together: `session.created` (seq 1,
1122
+ // actor the operator) commits in the same transaction as the insert —
1123
+ // there is no state where a session exists without its creation fact (D6).
1124
+ const validated = validateSessionEventWrite({
1125
+ actor: humanActor(operator),
1126
+ type: 'session.created',
1127
+ payload: input.title !== undefined ? { title: input.title } : {},
1128
+ })
1129
+ return this.writeTx(() => {
1130
+ // Store-assigned monotonic creation sequence: MAX+1 inside the write
1131
+ // transaction (every write runs BEGIN IMMEDIATE, so writers are
1132
+ // serialized) — the same pattern as the seq assignments in this file.
1133
+ // Sessions are never deleted, so the counter is never reused and the
1134
+ // listSessions same-millisecond tiebreak is stable over time.
1135
+ const tail = this.db
1136
+ .select({ max: sql<number | null>`max(${sessions.creationSeq})` })
1137
+ .from(sessions)
1138
+ .get()
1139
+ const creationSeq = (tail?.max ?? 0) + 1
1140
+ this.db
1141
+ .insert(sessions)
1142
+ .values({
1143
+ id,
1144
+ repo: input.repo,
1145
+ operator,
1146
+ title: input.title ?? null,
1147
+ creationSeq,
1148
+ createdAt: ts,
1149
+ updatedAt: ts,
1150
+ })
1151
+ .run()
1152
+ this.db
1153
+ .insert(sessionEvents)
1154
+ .values({
1155
+ session: id,
1156
+ seq: 1,
1157
+ ts,
1158
+ actor: validated.actor,
1159
+ type: validated.type,
1160
+ payload: validated.payload,
1161
+ })
1162
+ .run()
1163
+ return this.toSessionRecord(this.requireSession(id))
1164
+ })
1165
+ }
1166
+
1167
+ async getSession(id: string): Promise<SessionRecord | null> {
1168
+ const row = this.sessionRow(id)
1169
+ return row ? this.toSessionRecord(row) : null
1170
+ }
1171
+
1172
+ async listSessions(repo: string): Promise<SessionRecord[]> {
1173
+ // Pinned tiebreak (store/types.ts): createdAt ascending, then the
1174
+ // store-assigned monotonic creation sequence — never the random id.
1175
+ return this.db
1176
+ .select()
1177
+ .from(sessions)
1178
+ .where(eq(sessions.repo, repo))
1179
+ .orderBy(asc(sessions.createdAt), asc(sessions.creationSeq))
1180
+ .all()
1181
+ .map((row) => this.toSessionRecord(row))
1182
+ }
1183
+
1184
+ async appendSessionEvent<T extends SessionEventType>(
1185
+ id: string,
1186
+ event: SessionEventWrite<T>,
1187
+ ): Promise<SessionEventEnvelope<T>> {
1188
+ const validated = validateSessionEventWrite(event)
1189
+ return this.writeTx(() => this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>)
1190
+ }
1191
+
1192
+ async getSessionEvents(
1193
+ id: string,
1194
+ sinceSeq = 0,
1195
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
1196
+ ): Promise<SessionEvent[]> {
1197
+ const read = async (): Promise<SessionEvent[]> => {
1198
+ this.requireSession(id)
1199
+ const rows = this.db
1200
+ .select()
1201
+ .from(sessionEvents)
1202
+ .where(and(eq(sessionEvents.session, id), gt(sessionEvents.seq, sinceSeq)))
1203
+ .orderBy(asc(sessionEvents.seq))
1204
+ .all()
1205
+ return rows.map(
1206
+ (row) =>
1207
+ ({
1208
+ session: row.session,
1209
+ seq: row.seq,
1210
+ ts: row.ts,
1211
+ actor: row.actor,
1212
+ type: row.type,
1213
+ payload: row.payload,
1214
+ }) as SessionEvent,
1215
+ )
1216
+ }
1217
+ return readEventsWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
1218
+ }
1219
+
1220
+ /** Runs inside an open transaction — see `depositInTx` for `prune`. */
1221
+ private depositSessionInTx(id: string, prepared: PreparedArtifact): SessionArtifactMeta {
1222
+ this.requireSession(id)
1223
+ const createdAt = this.now()
1224
+ const row = this.db
1225
+ .select({ max: sql<number | null>`max(${sessionArtifacts.revision})` })
1226
+ .from(sessionArtifacts)
1227
+ .where(and(eq(sessionArtifacts.session, id), eq(sessionArtifacts.kind, prepared.kind)))
1228
+ .get()
1229
+ const revision = (row?.max ?? -1) + 1
1230
+ this.db
1231
+ .insert(sessionArtifacts)
1232
+ .values({
1233
+ session: id,
1234
+ kind: prepared.kind,
1235
+ revision,
1236
+ blobRef: prepared.blobRef,
1237
+ metadata: prepared.metadata,
1238
+ createdAt,
1239
+ })
1240
+ .run()
1241
+ this.db.update(sessions).set({ updatedAt: createdAt }).where(eq(sessions.id, id)).run()
1242
+ return {
1243
+ session: id,
1244
+ kind: prepared.kind,
1245
+ revision,
1246
+ blobRef: prepared.blobRef,
1247
+ metadata: prepared.metadata,
1248
+ createdAt,
1249
+ }
1250
+ }
1251
+
1252
+ async appendSessionWithArtifacts<T extends SessionEventType>(
1253
+ id: string,
1254
+ artifactInputs: ArtifactInput[],
1255
+ makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
1256
+ ): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
1257
+ const prepared: PreparedArtifact[] = []
1258
+ for (const input of artifactInputs) {
1259
+ prepared.push(await this.prepareArtifact(input))
1260
+ }
1261
+ // One synchronous transaction: deposits + event append commit together;
1262
+ // an invalid event throws, rolling back every deposit (D6). Session
1263
+ // artifacts are not retention-managed, so the AUT-322 ordering invariant
1264
+ // holds trivially — validation still precedes every deposit commit.
1265
+ return this.writeTx(() => {
1266
+ const deposited = prepared.map((p) => this.depositSessionInTx(id, p))
1267
+ const validated = validateSessionEventWrite(makeEvent(deposited))
1268
+ const event = this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>
1269
+ return { event, artifacts: deposited }
1270
+ })
1271
+ }
1272
+
1273
+ async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
1274
+ const prepared = await this.prepareArtifact(artifact)
1275
+ return this.writeTx(() => this.depositSessionInTx(id, prepared))
1276
+ }
1277
+
1278
+ private toSessionMeta(row: typeof sessionArtifacts.$inferSelect): SessionArtifactMeta {
1279
+ return {
1280
+ session: row.session,
1281
+ kind: row.kind,
1282
+ revision: row.revision,
1283
+ blobRef: row.blobRef,
1284
+ metadata: row.metadata,
1285
+ createdAt: row.createdAt,
1286
+ }
1287
+ }
1288
+
1289
+ async getSessionArtifact(
1290
+ id: string,
1291
+ kind: string,
1292
+ rev?: number,
1293
+ ): Promise<SessionArtifact | null> {
1294
+ this.requireSession(id)
1295
+ const scoped = and(eq(sessionArtifacts.session, id), eq(sessionArtifacts.kind, kind))
1296
+ const row =
1297
+ rev === undefined
1298
+ ? this.db
1299
+ .select()
1300
+ .from(sessionArtifacts)
1301
+ .where(scoped)
1302
+ .orderBy(desc(sessionArtifacts.revision))
1303
+ .limit(1)
1304
+ .get()
1305
+ : this.db
1306
+ .select()
1307
+ .from(sessionArtifacts)
1308
+ .where(and(scoped, eq(sessionArtifacts.revision, rev)))
1309
+ .get()
1310
+ if (!row) return null
1311
+ const content = await this.blobs.get(row.blobRef)
1312
+ return content ? { meta: this.toSessionMeta(row), content } : null
1313
+ }
1314
+
1315
+ async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
1316
+ this.requireSession(id)
1317
+ const where = kind
1318
+ ? and(eq(sessionArtifacts.session, id), eq(sessionArtifacts.kind, kind))
1319
+ : eq(sessionArtifacts.session, id)
1320
+ return this.db
1321
+ .select()
1322
+ .from(sessionArtifacts)
1323
+ .where(where)
1324
+ .orderBy(asc(sessionArtifacts.kind), asc(sessionArtifacts.revision))
1325
+ .all()
1326
+ .map((row) => this.toSessionMeta(row))
1327
+ }
1328
+
1329
+ subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
1330
+ return pollingSubscribe((since) => this.getEvents(slug, since), opts, onEvent)
1331
+ }
1332
+
1333
+ // ── Streams (SPEC §7.6 — the third primitive) ───────────────────────────
1334
+
1335
+ private streamRow(id: string): typeof streams.$inferSelect | undefined {
1336
+ return this.db.select().from(streams).where(eq(streams.id, id)).get()
1337
+ }
1338
+
1339
+ private requireStream(id: string): typeof streams.$inferSelect {
1340
+ const row = this.streamRow(id)
1341
+ if (!row) throw new Error(`unknown stream "${id}"`)
1342
+ return row
1343
+ }
1344
+
1345
+ private streamScopeOf(row: typeof streams.$inferSelect): StreamScope {
1346
+ if (row.scopeKind === 'build' && row.build !== null) return { kind: 'build', build: row.build }
1347
+ if (row.scopeKind === 'repo' && row.repo !== null) return { kind: 'repo', repo: row.repo }
1348
+ if (row.scopeKind === 'session' && row.session !== null) {
1349
+ return { kind: 'session', session: row.session }
1350
+ }
1351
+ throw new Error(`stream "${row.id}" has an unreadable scope`)
1352
+ }
1353
+
1354
+ private toStreamRecord(row: typeof streams.$inferSelect): StreamRecord {
1355
+ const record: StreamRecord = {
1356
+ id: row.id,
1357
+ scope: this.streamScopeOf(row),
1358
+ label: row.label,
1359
+ format: row.format as StreamRecord['format'],
1360
+ status: row.status as StreamRecord['status'],
1361
+ createdAt: row.createdAt,
1362
+ ...(row.closedAt ? { closedAt: row.closedAt } : {}),
1363
+ ...(row.outcome ? { outcome: row.outcome as StreamRecord['outcome'] } : {}),
1364
+ ...(row.artifactKind && row.artifactRevision !== null && row.artifactBlobRef
1365
+ ? {
1366
+ artifact: {
1367
+ kind: row.artifactKind,
1368
+ revision: row.artifactRevision,
1369
+ blobRef: row.artifactBlobRef,
1370
+ },
1371
+ }
1372
+ : {}),
1373
+ }
1374
+ return record
1375
+ }
1376
+
1377
+ /** Runs inside an open transaction — see `appendInTx`. The retention rule
1378
+ * (deposit-path, count-based like artifact retention): keep every closed
1379
+ * stream's chunks except the most recently closed one in this scope,
1380
+ * ordering by closedAt with an id tie-break. Records, finalized artifacts,
1381
+ * and open streams are never touched. */
1382
+ private pruneStreamChunksInTx(scope: StreamScope): void {
1383
+ const owner =
1384
+ scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
1385
+ const closed = this.db
1386
+ .select()
1387
+ .from(streams)
1388
+ .where(and(eq(streams.status, 'closed'), eq(streams.scopeKind, scope.kind)))
1389
+ .all()
1390
+ .filter((row) =>
1391
+ scope.kind === 'build'
1392
+ ? row.build === owner
1393
+ : scope.kind === 'repo'
1394
+ ? row.repo === owner
1395
+ : row.session === owner,
1396
+ )
1397
+ .sort(
1398
+ (a, b) => (a.closedAt ?? '').localeCompare(b.closedAt ?? '') || a.id.localeCompare(b.id),
1399
+ )
1400
+ for (const row of closed.slice(0, -1)) {
1401
+ this.db.delete(streamChunks).where(eq(streamChunks.stream, row.id)).run()
1402
+ }
1403
+ }
1404
+
1405
+ async createStream(scope: StreamScope, label: string): Promise<StreamRecord> {
1406
+ if (!label) throw new Error('stream label is required')
1407
+ if (scope.kind === 'build') this.requireBuild(scope.build)
1408
+ else if (scope.kind === 'repo') this.requireRepo(scope.repo)
1409
+ else this.requireSession(scope.session)
1410
+ const id = `st_${crypto.randomUUID()}`
1411
+ return this.writeTx(() => {
1412
+ // Retention prune and the insert land in one transaction.
1413
+ this.pruneStreamChunksInTx(scope)
1414
+ // Store-assigned monotonic creation sequence: MAX+1 inside the write
1415
+ // transaction (every write runs BEGIN IMMEDIATE, so writers are
1416
+ // serialized) — the same pattern as the seq assignments in this file.
1417
+ // Streams are never deleted (retention prunes stream_chunks only), so
1418
+ // the counter is never reused and the listStreams same-millisecond
1419
+ // tiebreak is stable over time.
1420
+ const tail = this.db
1421
+ .select({ max: sql<number | null>`max(${streams.creationSeq})` })
1422
+ .from(streams)
1423
+ .get()
1424
+ const creationSeq = (tail?.max ?? 0) + 1
1425
+ this.db
1426
+ .insert(streams)
1427
+ .values({
1428
+ id,
1429
+ scopeKind: scope.kind,
1430
+ build: scope.kind === 'build' ? scope.build : null,
1431
+ repo: scope.kind === 'repo' ? scope.repo : null,
1432
+ session: scope.kind === 'session' ? scope.session : null,
1433
+ label,
1434
+ format: STREAM_FORMAT,
1435
+ status: 'open',
1436
+ createdAt: this.now(),
1437
+ creationSeq,
1438
+ })
1439
+ .run()
1440
+ return this.toStreamRecord(this.requireStream(id))
1441
+ })
1442
+ }
1443
+
1444
+ async appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
1445
+ // Validate and size-check before taking the per-stream lock, so invalid
1446
+ // input keeps its current error precedence (AUT-348).
1447
+ validateStreamParts(parts)
1448
+ const bytes = serializedBatchSize(parts)
1449
+ if (bytes > STREAM_BATCH_MAX_BYTES) throw new StreamBatchTooLargeError(bytes)
1450
+ // The per-stream lock covers the closed check and the transaction as one
1451
+ // critical section: an append issued during a same-process close waits
1452
+ // for the close to commit, then fails the status check with an explicit
1453
+ // StreamClosedError instead of being silently omitted from the artifact.
1454
+ return this.streamLocks.run(streamId, () =>
1455
+ this.writeTx(() => {
1456
+ const row = this.requireStream(streamId)
1457
+ if (row.status === 'closed') throw new StreamClosedError(streamId)
1458
+ const tails = this.db
1459
+ .select({ max: sql<number | null>`max(${streamChunks.seq})` })
1460
+ .from(streamChunks)
1461
+ .where(eq(streamChunks.stream, streamId))
1462
+ .get()
1463
+ const chunk: StreamChunk = {
1464
+ stream: streamId,
1465
+ seq: (tails?.max ?? 0) + 1,
1466
+ ts: this.now(),
1467
+ parts: structuredClone(parts),
1468
+ }
1469
+ this.db
1470
+ .insert(streamChunks)
1471
+ .values({ stream: streamId, seq: chunk.seq, ts: chunk.ts, parts: chunk.parts })
1472
+ .run()
1473
+ return chunk
1474
+ }),
1475
+ )
1476
+ }
1477
+
1478
+ async readStream(
1479
+ streamId: string,
1480
+ opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
1481
+ ): Promise<StreamRead> {
1482
+ const read = async (): Promise<StreamRead> => {
1483
+ const row = this.requireStream(streamId)
1484
+ const chunks = this.db
1485
+ .select()
1486
+ .from(streamChunks)
1487
+ .where(and(eq(streamChunks.stream, streamId), gt(streamChunks.seq, opts?.since ?? 0)))
1488
+ .orderBy(asc(streamChunks.seq))
1489
+ .all()
1490
+ const record = this.toStreamRecord(row)
1491
+ return {
1492
+ chunks: chunks.map((chunk) => ({
1493
+ stream: chunk.stream,
1494
+ seq: chunk.seq,
1495
+ ts: chunk.ts,
1496
+ parts: chunk.parts,
1497
+ })),
1498
+ status: record.status,
1499
+ ...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
1500
+ ...(record.artifact !== undefined ? { artifact: record.artifact } : {}),
1501
+ }
1502
+ }
1503
+ return readStreamWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
1504
+ }
1505
+
1506
+ async closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
1507
+ // The per-stream mutex holds across the entire prepare→commit loop
1508
+ // (AUT-348): a same-process append issued during this close waits for it
1509
+ // to finish and then rejects on the closed check with StreamClosedError.
1510
+ // Cross-connection appends (a second instance on the same file) are
1511
+ // invisible to the mutex, so the commit transaction re-verifies the
1512
+ // chunk tail and a mismatch retries the whole loop from a newer snapshot.
1513
+ return this.streamLocks.run(streamId, async () => {
1514
+ for (;;) {
1515
+ const row = this.requireStream(streamId)
1516
+ if (row.status === 'closed') return this.toStreamRecord(row)
1517
+ // Prepare phase — assemble and store the blob before the transaction
1518
+ // (D6 shape: content-addressed orphan blobs are harmless; a deposit
1519
+ // failure leaves the stream open and unwritten).
1520
+ const chunkRows = this.db
1521
+ .select()
1522
+ .from(streamChunks)
1523
+ .where(eq(streamChunks.stream, streamId))
1524
+ .orderBy(asc(streamChunks.seq))
1525
+ .all()
1526
+ const { document, droppedPartCount } = await assembleUIMessageDocument(
1527
+ chunkRows.flatMap((chunk) => chunk.parts),
1528
+ )
1529
+ const scope = this.streamScopeOf(row)
1530
+ const input = streamArtifactInput(
1531
+ row.id,
1532
+ scope,
1533
+ row.label,
1534
+ outcome,
1535
+ document,
1536
+ chunkRows.length,
1537
+ droppedPartCount,
1538
+ )
1539
+ const prepared: PreparedArtifact = {
1540
+ kind: input.kind,
1541
+ blobRef: contentHash(toBytes(input.content)),
1542
+ metadata: structuredClone(input.metadata),
1543
+ }
1544
+ await this.blobs.put(prepared.blobRef, toBytes(input.content))
1545
+ // Commit phase — one synchronous transaction: the artifact deposit
1546
+ // and the close land together or not at all. A close that raced us
1547
+ // through the prepare phase wins; ours re-reads and returns its
1548
+ // record. The chunk tail is re-read under the same write lock:
1549
+ // chunk seqs are gapless 1..n (assigned as MAX+1, and retention
1550
+ // never deletes the closing stream's chunks), so a MAX other than
1551
+ // the prepared length means a foreign appender landed inside the
1552
+ // prepare window.
1553
+ const committed = this.writeTx(
1554
+ (): { stale: true } | { stale: false; record: StreamRecord } => {
1555
+ const fresh = this.requireStream(streamId)
1556
+ if (fresh.status === 'closed')
1557
+ return { stale: false, record: this.toStreamRecord(fresh) }
1558
+ const tail = this.db
1559
+ .select({ max: sql<number | null>`max(${streamChunks.seq})` })
1560
+ .from(streamChunks)
1561
+ .where(eq(streamChunks.stream, streamId))
1562
+ .get()
1563
+ if ((tail?.max ?? 0) !== chunkRows.length) return { stale: true }
1564
+ const meta =
1565
+ scope.kind === 'build'
1566
+ ? this.depositInTx(scope.build, prepared)
1567
+ : scope.kind === 'repo'
1568
+ ? this.depositRepoInTx(scope.repo, prepared)
1569
+ : this.depositSessionInTx(scope.session, prepared)
1570
+ this.db
1571
+ .update(streams)
1572
+ .set({
1573
+ status: 'closed',
1574
+ outcome,
1575
+ closedAt: meta.createdAt,
1576
+ artifactKind: meta.kind,
1577
+ artifactRevision: meta.revision,
1578
+ artifactBlobRef: meta.blobRef,
1579
+ })
1580
+ .where(eq(streams.id, streamId))
1581
+ .run()
1582
+ return { stale: false, record: this.toStreamRecord(this.requireStream(streamId)) }
1583
+ },
1584
+ )
1585
+ if (!committed.stale) return committed.record
1586
+ // A foreign appender landed inside the prepare window; loop and
1587
+ // re-prepare from the newer chunk set so its acknowledged chunk is
1588
+ // not omitted from the finalized artifact.
1589
+ }
1590
+ })
1591
+ }
1592
+
1593
+ async getStream(streamId: string): Promise<StreamRecord | null> {
1594
+ const row = this.streamRow(streamId)
1595
+ return row ? this.toStreamRecord(row) : null
1596
+ }
1597
+
1598
+ async listStreams(scope: StreamScope): Promise<StreamRecord[]> {
1599
+ const owner =
1600
+ scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
1601
+ // Pinned tiebreak (store/types.ts): createdAt ascending, then the
1602
+ // store-assigned monotonic creation sequence — never the random id.
1603
+ const rows = this.db
1604
+ .select()
1605
+ .from(streams)
1606
+ .where(eq(streams.scopeKind, scope.kind))
1607
+ .orderBy(asc(streams.createdAt), asc(streams.creationSeq))
1608
+ .all()
1609
+ .filter((row) =>
1610
+ scope.kind === 'build'
1611
+ ? row.scopeKind === 'build' && row.build === owner
1612
+ : scope.kind === 'repo'
1613
+ ? row.scopeKind === 'repo' && row.repo === owner
1614
+ : row.scopeKind === 'session' && row.session === owner,
1615
+ )
1616
+ return rows.map((row) => this.toStreamRecord(row))
1617
+ }
1618
+
1619
+ async close(): Promise<void> {
1620
+ this.sqlite.close()
1621
+ }
1622
+ }
1623
+
1624
+ export type LocalStoreInspection =
1625
+ | { status: 'absent'; databasePath: string }
1626
+ | { status: 'present'; databasePath: string; buildCount: number }
1627
+
1628
+ function isMissing(error: unknown): boolean {
1629
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
1630
+ }
1631
+
1632
+ /**
1633
+ * Inspect a repository-local Store without asking SQLite to open its source
1634
+ * files. Bun can create WAL/SHM files even for a read-only connection, so the
1635
+ * database and any live sidecars are copied to a disposable directory first.
1636
+ */
1637
+ export async function inspectLocalStoreSnapshot(rootDir: string): Promise<LocalStoreInspection> {
1638
+ const databasePath = join(rootDir, 'autobuild.sqlite')
1639
+ try {
1640
+ await access(databasePath)
1641
+ } catch (error) {
1642
+ if (isMissing(error)) return { status: 'absent', databasePath }
1643
+ throw error
1644
+ }
1645
+
1646
+ const snapshotRoot = await mkdtemp(join(tmpdir(), 'ab-store-inspection-'))
1647
+ const snapshotPath = join(snapshotRoot, 'autobuild.sqlite')
1648
+ let database: Database | undefined
1649
+ let result: LocalStoreInspection | undefined
1650
+ let failure: unknown
1651
+ const cleanupFailures: unknown[] = []
1652
+ try {
1653
+ await copyFile(databasePath, snapshotPath)
1654
+ for (const suffix of ['-wal', '-shm']) {
1655
+ try {
1656
+ await copyFile(`${databasePath}${suffix}`, `${snapshotPath}${suffix}`)
1657
+ } catch (error) {
1658
+ if (!isMissing(error)) throw error
1659
+ }
1660
+ }
1661
+ database = new Database(snapshotPath, { readonly: true })
1662
+ const row = database.query('SELECT count(*) AS count FROM builds').get() as {
1663
+ count: number
1664
+ } | null
1665
+ if (row === null) throw new Error('local Store builds count returned no row')
1666
+ result = { status: 'present', databasePath, buildCount: row.count }
1667
+ } catch (error) {
1668
+ failure = error
1669
+ } finally {
1670
+ try {
1671
+ database?.close()
1672
+ } catch (error) {
1673
+ cleanupFailures.push(error)
1674
+ }
1675
+ try {
1676
+ await rm(snapshotRoot, { recursive: true, force: true })
1677
+ } catch (error) {
1678
+ cleanupFailures.push(error)
1679
+ }
1680
+ }
1681
+
1682
+ if (failure !== undefined && cleanupFailures.length > 0) {
1683
+ throw new AggregateError(
1684
+ [failure, ...cleanupFailures],
1685
+ 'local Store snapshot inspection and cleanup both failed',
1686
+ )
1687
+ }
1688
+ if (failure !== undefined) throw failure
1689
+ if (cleanupFailures.length === 1) throw cleanupFailures[0]
1690
+ if (cleanupFailures.length > 1)
1691
+ throw new AggregateError(cleanupFailures, 'local Store snapshot cleanup failed')
1692
+ if (result === undefined) throw new Error('local Store snapshot inspection produced no result')
1693
+ return result
1694
+ }
1695
+
1696
+ /**
1697
+ * Open the local store (SPEC §7.2.1): `<root>/autobuild.sqlite` plus a
1698
+ * content-addressed blob directory at `<root>/blobs`.
1699
+ */
1700
+ export function openLocalStore(
1701
+ rootDir: string,
1702
+ opts: { clock?: Clock; retention?: { maxRevisions?: number } } = {},
1703
+ ): SqliteBuildStore {
1704
+ mkdirSync(rootDir, { recursive: true })
1705
+ const database = new Database(join(rootDir, 'autobuild.sqlite'), { create: true })
1706
+ const blobs = new DirBlobStore(join(rootDir, 'blobs'))
1707
+ return new SqliteBuildStore({
1708
+ database,
1709
+ blobs,
1710
+ ...(opts.clock ? { clock: opts.clock } : {}),
1711
+ ...(opts.retention ? { retention: opts.retention } : {}),
1712
+ })
1713
+ }