@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,2581 @@
1
+ /**
2
+ * Reusable contract suites for the BuildStore seam (SPEC §7). The in-memory
3
+ * adapter's behavior *is* the contract; every adapter — SQLite (§7.2.1),
4
+ * remote HTTP (§7.2.2) — runs these identical suites unchanged.
5
+ *
6
+ * Factories receive an optional injectable clock (mirroring the adapters'
7
+ * constructors) because lease expiry (§15.2.6, §7.4) and store-assigned
8
+ * timestamps (§15.1) are only testable deterministically with time control.
9
+ * The retention option drives the artifact-retention suite (store/retention.ts);
10
+ * factories must wire it into the adapter so the suite runs identically
11
+ * against every adapter.
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { EventValidationError, type EventWrite } from '../events/catalog'
15
+ import { agentActor, DISPATCHER, humanActor, KERNEL, type Via } from '../events/envelope'
16
+ import type { RepositoryEventWrite } from '../events/repository'
17
+ import type { SessionEventWrite } from '../events/sessions'
18
+ import { manualClock } from '../testing/fixed'
19
+ import { AuthError } from './remote/client'
20
+ import {
21
+ contentHash,
22
+ textContent,
23
+ toBytes,
24
+ type BlobStore,
25
+ type BuildStore,
26
+ type Clock,
27
+ type NewBuildInput,
28
+ } from './types'
29
+
30
+ /** Decode artifact bytes — accepts both build and repository artifacts. */
31
+ function artifactText(artifact: { content: Uint8Array }): string {
32
+ return new TextDecoder().decode(artifact.content)
33
+ }
34
+ import {
35
+ clampWaitSeconds,
36
+ MAX_STREAM_WAIT_SECONDS,
37
+ StreamBatchTooLargeError,
38
+ StreamClosedError,
39
+ type StreamPart,
40
+ } from './streams/types'
41
+
42
+ // ── Factory seams ────────────────────────────────────────────────────────────
43
+
44
+ export interface BuildStoreHarness {
45
+ store: BuildStore
46
+ /**
47
+ * Delegated-write authority posture (SPEC §15.1). Absent: no token
48
+ * authority — the backing catalog is the only via gate (local adapters; the
49
+ * open no-secret remote server), and the catalog's EventValidationError is
50
+ * the sole rejection. Present: writes are authorized by a bearer token and
51
+ * `via` is the claim it carries. A token without a via claim may not write
52
+ * delegated events at all — any human write claiming a via, valid or
53
+ * malformed, rejects with AuthError (403 "token carries no via; it may not
54
+ * write delegated events") before catalog validation, the deliberate
55
+ * authority-vs-validation ordering of the remote server's enforceVia —
56
+ * while via-less human writes still round-trip. A token carrying a via
57
+ * stamps it onto human writes and accepts only that via (a write claiming
58
+ * a different via rejects with AuthError).
59
+ */
60
+ viaAuthority?: { via?: Via }
61
+ cleanup?: () => Promise<void>
62
+ }
63
+
64
+ /**
65
+ * Adapters are constructed with an injectable clock (see MemoryBuildStore's
66
+ * constructor); the factory passes it through so the suite controls time.
67
+ */
68
+ export type BuildStoreFactory = (opts?: {
69
+ clock?: Clock
70
+ retention?: { maxRevisions: number }
71
+ }) => Promise<BuildStoreHarness>
72
+
73
+ export interface BlobStoreHarness {
74
+ blobs: BlobStore
75
+ cleanup?: () => Promise<void>
76
+ }
77
+
78
+ export type BlobStoreFactory = () => Promise<BlobStoreHarness>
79
+
80
+ // ── Shared fixtures (exported so wave-2 adapters reuse them) ─────────────────
81
+
82
+ /** The contract's fixed epoch — matches `manualClock`'s default. */
83
+ export const CONTRACT_T0 = '2026-07-15T12:00:00.000Z'
84
+
85
+ /** `Date.toISOString()` shape — what a store-assigned `ts` must look like. */
86
+ export const ISO_TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
87
+
88
+ export function sampleBuildInput(
89
+ slug: string,
90
+ opts: { repoOrigin?: string; repo?: string } = {},
91
+ ): NewBuildInput {
92
+ return {
93
+ slug,
94
+ // Identity-shaped by default: a normalized origin URL. The store treats
95
+ // `repo` as an opaque key — path-keyed legacy records round-trip the same
96
+ // way (see the identity suites below).
97
+ repo: opts.repo ?? 'https://github.com/acme/rate-limiter',
98
+ ...(opts.repoOrigin !== undefined ? { repoOrigin: opts.repoOrigin } : {}),
99
+ ticket: {
100
+ source: 'linear',
101
+ id: 'TICK-1',
102
+ url: 'https://linear.app/acme/issue/TICK-1',
103
+ title: 'Add rate limiting to auth',
104
+ },
105
+ branch: `ab/${slug}`,
106
+ }
107
+ }
108
+
109
+ /** A minimal always-valid write — observations may come from any phase (§12). */
110
+ export function sampleEventWrite(
111
+ summary = 'sample observation',
112
+ ): EventWrite<'observation.recorded'> {
113
+ return {
114
+ actor: agentActor('implement', 's_test'),
115
+ type: 'observation.recorded',
116
+ payload: { id: 'o_1', kind: 'followup', summary },
117
+ }
118
+ }
119
+
120
+ export function harvestStartedWrite(run = 'h_1', rev = 0): RepositoryEventWrite<'harvest.started'> {
121
+ return {
122
+ actor: KERNEL,
123
+ type: 'harvest.started',
124
+ payload: {
125
+ run,
126
+ observations: [{ build: 'build-a', seq: 1 }],
127
+ scan: { kind: 'harvest-scan', rev },
128
+ },
129
+ }
130
+ }
131
+
132
+ export function buildCreatedWrite(): EventWrite<'build.created'> {
133
+ return {
134
+ actor: DISPATCHER,
135
+ type: 'build.created',
136
+ payload: {
137
+ ticket: {
138
+ source: 'linear',
139
+ id: 'TICK-1',
140
+ url: 'https://linear.app/acme/issue/TICK-1',
141
+ title: 'Add rate limiting to auth',
142
+ },
143
+ repo: 'acme/rate-limiter',
144
+ baseBranch: 'main',
145
+ },
146
+ }
147
+ }
148
+
149
+ export function planCompletedWrite(rev: number, round = 1): EventWrite<'plan.completed'> {
150
+ return {
151
+ actor: agentActor('plan', 's_plan'),
152
+ type: 'plan.completed',
153
+ payload: {
154
+ round,
155
+ artifact: { kind: 'plan', rev },
156
+ verifySteps: ['types', 'unit'],
157
+ },
158
+ }
159
+ }
160
+
161
+ export function runStartedWrite(
162
+ run: string,
163
+ deposited: { kind: string; revision: number }[],
164
+ ): RepositoryEventWrite<'dispatcher.run-started'> {
165
+ const artifact = deposited[0]
166
+ if (!artifact) throw new Error('run-started deposit returned no artifact')
167
+ return {
168
+ actor: DISPATCHER,
169
+ type: 'dispatcher.run-started',
170
+ payload: {
171
+ run,
172
+ pid: 4242,
173
+ effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
174
+ roleWarnings: [],
175
+ },
176
+ }
177
+ }
178
+
179
+ export function messagePostedWrite(text = 'hello'): SessionEventWrite<'message.posted'> {
180
+ return { actor: humanActor('operator'), type: 'message.posted', payload: { text } }
181
+ }
182
+
183
+ export function turnStartedWrite(
184
+ turn = 't1',
185
+ stream = `st_${turn}`,
186
+ ): SessionEventWrite<'turn.started'> {
187
+ return {
188
+ actor: agentActor('orchestrator', 'os_turn'),
189
+ type: 'turn.started',
190
+ payload: { turn, stream, trigger: { kind: 'message', messageSeq: 1 } },
191
+ }
192
+ }
193
+
194
+ async function withStore(
195
+ factory: BuildStoreFactory,
196
+ opts: { clock?: Clock; retention?: { maxRevisions: number } } | undefined,
197
+ run: (store: BuildStore, viaAuthority?: { via?: Via }) => Promise<void>,
198
+ ): Promise<void> {
199
+ const { store, viaAuthority, cleanup } = await factory(opts)
200
+ try {
201
+ await run(store, viaAuthority)
202
+ } finally {
203
+ await store.close()
204
+ await cleanup?.()
205
+ }
206
+ }
207
+
208
+ /** Delivery wait for the §7.2 subscribe tests: poll the predicate every 5 ms
209
+ * and fail with `message` only if it never turns true within `deadlineMs`.
210
+ * Replaces fixed 50 ms sleeps that bet on delivery landing inside a
211
+ * wall-clock window — over real HTTP + Postgres round-trips the first poll
212
+ * can land past any small window, while a genuinely broken delivery path
213
+ * still fails at the deadline. */
214
+ async function waitForDelivery(
215
+ predicate: () => boolean,
216
+ message: string,
217
+ deadlineMs = 10_000,
218
+ ): Promise<void> {
219
+ const deadline = performance.now() + deadlineMs
220
+ while (!predicate()) {
221
+ if (performance.now() > deadline) {
222
+ throw new Error(`timed out waiting for ${message}`)
223
+ }
224
+ await Bun.sleep(5)
225
+ }
226
+ }
227
+
228
+ // ── Stream fixtures (SPEC §7.6) ──────────────────────────────────────────
229
+
230
+ /** The representative part sequence the assembly close test asserts on:
231
+ * text deltas, a tool call with input and output, a reasoning block, a
232
+ * data-* part, an error chunk, and one undefined-type part. */
233
+ export function representativeStreamParts(): StreamPart[] {
234
+ return [
235
+ { type: 'start', messageId: 'msg-1' },
236
+ { type: 'text-start', id: 't1' },
237
+ { type: 'text-delta', id: 't1', delta: 'working' },
238
+ { type: 'text-end', id: 't1' },
239
+ { type: 'reasoning-start', id: 'r1' },
240
+ { type: 'reasoning-delta', id: 'r1', delta: 'considering' },
241
+ { type: 'reasoning-end', id: 'r1' },
242
+ { type: 'tool-input-available', toolCallId: 'c1', toolName: 'read', input: { path: 'a.ts' } },
243
+ { type: 'tool-output-available', toolCallId: 'c1', output: { lines: 10 } },
244
+ { type: 'data-probe', id: 'd1', data: { n: 1 } },
245
+ { type: 'error', errorText: 'transient' },
246
+ { type: 'mystery-part', foo: 1 },
247
+ ]
248
+ }
249
+
250
+ /** The document the representative sequence assembles into — shared by the
251
+ * close test and each adapter's expected-artifact assertion. */
252
+ export function representativeStreamDocument(): unknown[] {
253
+ return [
254
+ {
255
+ id: 'msg-1',
256
+ role: 'assistant',
257
+ parts: [
258
+ { type: 'text', text: 'working', state: 'done' },
259
+ { type: 'reasoning', id: 'r1', text: 'considering', state: 'done' },
260
+ {
261
+ type: 'tool-read',
262
+ toolCallId: 'c1',
263
+ state: 'output-available',
264
+ input: { path: 'a.ts' },
265
+ output: { lines: 10 },
266
+ },
267
+ { type: 'data-probe', id: 'd1', data: { n: 1 } },
268
+ ],
269
+ },
270
+ ]
271
+ }
272
+
273
+ function atT0(offsetMs: number): string {
274
+ return new Date(Date.parse(CONTRACT_T0) + offsetMs).toISOString()
275
+ }
276
+
277
+ // ── The BuildStore contract ──────────────────────────────────────────────────
278
+
279
+ export function describeBuildStoreContract(name: string, factory: BuildStoreFactory): void {
280
+ describe(`BuildStore contract: ${name}`, () => {
281
+ describe('builds', () => {
282
+ test('createBuild returns a record with store-assigned createdAt/updatedAt', async () => {
283
+ const clock = manualClock(CONTRACT_T0)
284
+ await withStore(factory, { clock }, async (store) => {
285
+ const input = sampleBuildInput('create-a')
286
+ const record = await store.createBuild(input)
287
+ expect(record.slug).toBe('create-a')
288
+ expect(record.repo).toBe(input.repo)
289
+ expect(record.ticket).toEqual(input.ticket)
290
+ expect(record.branch).toBe('ab/create-a')
291
+ expect(record.createdAt).toBe(CONTRACT_T0)
292
+ expect(record.updatedAt).toBe(CONTRACT_T0)
293
+ expect(await store.getBuild('create-a')).toEqual(record)
294
+ })
295
+ })
296
+
297
+ test('duplicate slug rejects', async () => {
298
+ await withStore(factory, undefined, async (store) => {
299
+ await store.createBuild(sampleBuildInput('dupe'))
300
+ const err = await store.createBuild(sampleBuildInput('dupe')).catch((e: unknown) => e)
301
+ expect(err).toBeInstanceOf(Error)
302
+ expect((await store.listBuilds()).length).toBe(1)
303
+ })
304
+ })
305
+
306
+ test('createBuild persists repoOrigin; the field stays absent without one', async () => {
307
+ await withStore(factory, undefined, async (store) => {
308
+ const origin = 'https://github.com/acme/rate-limiter'
309
+ const withOrigin = await store.createBuild(
310
+ sampleBuildInput('origin-set', { repoOrigin: origin }),
311
+ )
312
+ expect(withOrigin.repoOrigin).toBe(origin)
313
+ expect((await store.getBuild('origin-set'))?.repoOrigin).toBe(origin)
314
+ expect(
315
+ (await store.listBuilds()).find((build) => build.slug === 'origin-set')?.repoOrigin,
316
+ ).toBe(origin)
317
+
318
+ await store.createBuild(sampleBuildInput('origin-absent'))
319
+ expect((await store.getBuild('origin-absent'))?.repoOrigin).toBeUndefined()
320
+ })
321
+ })
322
+
323
+ test('getBuild returns null for an unknown slug', async () => {
324
+ await withStore(factory, undefined, async (store) => {
325
+ expect(await store.getBuild('never-created')).toBeNull()
326
+ })
327
+ })
328
+
329
+ test('listBuilds lists all builds', async () => {
330
+ await withStore(factory, undefined, async (store) => {
331
+ await store.createBuild(sampleBuildInput('list-a'))
332
+ await store.createBuild(sampleBuildInput('list-b'))
333
+ const slugs = (await store.listBuilds()).map((b) => b.slug).sort()
334
+ expect(slugs).toEqual(['list-a', 'list-b'])
335
+ })
336
+ })
337
+ })
338
+
339
+ describe('repository journal (workflow and control paper trail)', () => {
340
+ test('ensureRepo is idempotent and repo event seq is independent', async () => {
341
+ const clock = manualClock(CONTRACT_T0)
342
+ await withStore(factory, { clock }, async (store) => {
343
+ const first = await store.ensureRepo('acme/a')
344
+ expect(first).toEqual({
345
+ repo: 'acme/a',
346
+ createdAt: CONTRACT_T0,
347
+ updatedAt: CONTRACT_T0,
348
+ })
349
+ expect(await store.ensureRepo('acme/a')).toEqual(first)
350
+ await store.ensureRepo('acme/b')
351
+ const a1 = await store.appendRepo('acme/a', harvestStartedWrite('ha'))
352
+ const b1 = await store.appendRepo('acme/b', harvestStartedWrite('hb'))
353
+ expect([a1.seq, b1.seq]).toEqual([1, 1])
354
+ expect((await store.getRepoEvents('acme/a'))[0]?.repo).toBe('acme/a')
355
+ })
356
+ })
357
+
358
+ test('repository event validation is strict and actor-aware', async () => {
359
+ await withStore(factory, undefined, async (store) => {
360
+ await store.ensureRepo('acme/a')
361
+ const err = await store
362
+ .appendRepo('acme/a', {
363
+ actor: agentActor('harvest', 'hs_1'),
364
+ type: 'harvest.started',
365
+ payload: harvestStartedWrite().payload,
366
+ })
367
+ .catch((error: unknown) => error)
368
+ expect(err).toBeInstanceOf(EventValidationError)
369
+ expect(await store.getRepoEvents('acme/a')).toEqual([])
370
+ })
371
+ })
372
+
373
+ test('dispatcher settings round-trip with strict payload and actor validation', async () => {
374
+ await withStore(factory, undefined, async (store) => {
375
+ await store.ensureRepo('acme/settings')
376
+ await store.appendRepo('acme/settings', {
377
+ actor: humanActor('operator'),
378
+ type: 'dispatcher.intake-set',
379
+ payload: { enabled: false },
380
+ })
381
+ await store.appendRepo('acme/settings', {
382
+ actor: humanActor('operator'),
383
+ type: 'dispatcher.auto-merge-default-set',
384
+ payload: { enabled: true },
385
+ })
386
+ await store.appendRepo('acme/settings', {
387
+ actor: humanActor('operator'),
388
+ type: 'dispatcher.pause-set',
389
+ payload: { enabled: true },
390
+ })
391
+
392
+ for (const invalid of [
393
+ {
394
+ actor: KERNEL,
395
+ type: 'dispatcher.intake-set',
396
+ payload: { enabled: true },
397
+ },
398
+ {
399
+ actor: humanActor('operator'),
400
+ type: 'dispatcher.auto-merge-default-set',
401
+ payload: { enabled: 'yes' },
402
+ },
403
+ {
404
+ actor: humanActor('operator'),
405
+ type: 'dispatcher.pause-set',
406
+ payload: { enabled: 'yes' },
407
+ },
408
+ ] as const) {
409
+ const error = await store
410
+ .appendRepo('acme/settings', invalid as RepositoryEventWrite)
411
+ .catch((caught: unknown) => caught)
412
+ expect(error).toBeInstanceOf(EventValidationError)
413
+ }
414
+
415
+ expect(await store.getRepoEvents('acme/settings')).toMatchObject([
416
+ {
417
+ seq: 1,
418
+ actor: humanActor('operator'),
419
+ type: 'dispatcher.intake-set',
420
+ payload: { enabled: false },
421
+ },
422
+ {
423
+ seq: 2,
424
+ actor: humanActor('operator'),
425
+ type: 'dispatcher.auto-merge-default-set',
426
+ payload: { enabled: true },
427
+ },
428
+ {
429
+ seq: 3,
430
+ actor: humanActor('operator'),
431
+ type: 'dispatcher.pause-set',
432
+ payload: { enabled: true },
433
+ },
434
+ ])
435
+ })
436
+ })
437
+
438
+ test('harvest control requests are human-only and acknowledgements are kernel-only', async () => {
439
+ await withStore(factory, undefined, async (store) => {
440
+ await store.ensureRepo('acme/control')
441
+ await store.appendRepo('acme/control', {
442
+ actor: humanActor('operator'),
443
+ type: 'harvest.pause-requested',
444
+ payload: {},
445
+ })
446
+ await store.appendRepo('acme/control', {
447
+ actor: KERNEL,
448
+ type: 'harvest.paused',
449
+ payload: {},
450
+ })
451
+
452
+ for (const invalid of [
453
+ {
454
+ actor: KERNEL,
455
+ type: 'harvest.resume-requested',
456
+ payload: {},
457
+ },
458
+ {
459
+ actor: humanActor('operator'),
460
+ type: 'harvest.resumed',
461
+ payload: {},
462
+ },
463
+ ] as const) {
464
+ const error = await store
465
+ .appendRepo('acme/control', invalid)
466
+ .catch((caught: unknown) => caught)
467
+ expect(error).toBeInstanceOf(EventValidationError)
468
+ }
469
+ expect((await store.getRepoEvents('acme/control')).map((event) => event.type)).toEqual([
470
+ 'harvest.pause-requested',
471
+ 'harvest.paused',
472
+ ])
473
+ })
474
+ })
475
+
476
+ test('repository artifacts are versioned and deposit atomically with events', async () => {
477
+ await withStore(factory, undefined, async (store) => {
478
+ await store.ensureRepo('acme/a')
479
+ const result = await store.appendRepoWithArtifacts(
480
+ 'acme/a',
481
+ [{ kind: 'harvest-scan', content: '{"observations":[]}' }],
482
+ (deposited) => harvestStartedWrite('h_atomic', deposited[0]!.revision),
483
+ )
484
+ expect(result.artifacts[0]?.revision).toBe(0)
485
+ expect(result.event.payload.scan).toEqual({
486
+ kind: 'harvest-scan',
487
+ rev: 0,
488
+ })
489
+ const artifact = await store.getRepoArtifact('acme/a', 'harvest-scan')
490
+ expect(new TextDecoder().decode(artifact?.content)).toBe('{"observations":[]}')
491
+
492
+ const error = await store
493
+ .appendRepoWithArtifacts('acme/a', [{ kind: 'harvest-scan', content: 'bad' }], () => ({
494
+ ...harvestStartedWrite('bad'),
495
+ actor: agentActor('x', 's'),
496
+ }))
497
+ .catch((caught: unknown) => caught)
498
+ expect(error).toBeInstanceOf(EventValidationError)
499
+ expect(
500
+ (await store.listRepoArtifacts('acme/a', 'harvest-scan')).map((meta) => meta.revision),
501
+ ).toEqual([0])
502
+ })
503
+ })
504
+
505
+ test('repository lease is exclusive, expires, heartbeats, and releases', async () => {
506
+ const clock = manualClock(CONTRACT_T0)
507
+ await withStore(factory, { clock }, async (store) => {
508
+ await store.ensureRepo('acme/a')
509
+ expect(await store.claimRepoLease('acme/a', 'one', 1000)).toBe(true)
510
+ expect(await store.claimRepoLease('acme/a', 'two', 1000)).toBe(false)
511
+ clock.advance(500)
512
+ expect(await store.heartbeatRepo('acme/a', 'one')).toBe(true)
513
+ clock.advance(900)
514
+ expect(await store.claimRepoLease('acme/a', 'two', 1000)).toBe(false)
515
+ await store.releaseRepoLease('acme/a', 'one')
516
+ expect(await store.claimRepoLease('acme/a', 'two', 1000)).toBe(true)
517
+ })
518
+ })
519
+ })
520
+
521
+ describe('append', () => {
522
+ test('assigns seq 1..n per build, independently across builds (§15.1)', async () => {
523
+ await withStore(factory, undefined, async (store) => {
524
+ await store.createBuild(sampleBuildInput('seq-a'))
525
+ await store.createBuild(sampleBuildInput('seq-b'))
526
+ const a1 = await store.append('seq-a', sampleEventWrite('a1'))
527
+ const a2 = await store.append('seq-a', sampleEventWrite('a2'))
528
+ const b1 = await store.append('seq-b', sampleEventWrite('b1'))
529
+ const a3 = await store.append('seq-a', sampleEventWrite('a3'))
530
+ expect([a1.seq, a2.seq, a3.seq]).toEqual([1, 2, 3])
531
+ expect(b1.seq).toBe(1)
532
+ })
533
+ })
534
+
535
+ test('returns the full envelope with store-assigned ISO ts from the injected clock', async () => {
536
+ const clock = manualClock(CONTRACT_T0)
537
+ await withStore(factory, { clock }, async (store) => {
538
+ await store.createBuild(sampleBuildInput('envelope'))
539
+ const envelope = await store.append('envelope', buildCreatedWrite())
540
+ expect(envelope).toEqual({
541
+ build: 'envelope',
542
+ seq: 1,
543
+ ts: CONTRACT_T0,
544
+ actor: { kind: 'dispatcher' },
545
+ type: 'build.created',
546
+ payload: buildCreatedWrite().payload,
547
+ })
548
+ expect(envelope.ts).toMatch(ISO_TS)
549
+ })
550
+ })
551
+
552
+ test('payload round-trips typed through getEvents', async () => {
553
+ await withStore(factory, undefined, async (store) => {
554
+ await store.createBuild(sampleBuildInput('round-trip'))
555
+ await store.append('round-trip', buildCreatedWrite())
556
+ const [event] = await store.getEvents('round-trip')
557
+ expect(event?.type).toBe('build.created')
558
+ if (event?.type !== 'build.created') throw new Error('unreachable')
559
+ expect(event.payload).toEqual(buildCreatedWrite().payload)
560
+ expect(event.payload.ticket.id).toBe('TICK-1')
561
+ expect(event.actor).toEqual({ kind: 'dispatcher' })
562
+ })
563
+ })
564
+ })
565
+
566
+ describe('appendIfCurrent (atomic stream compare-and-append)', () => {
567
+ test('expected sequence 0 appends to an empty stream and returns the envelope', async () => {
568
+ await withStore(factory, undefined, async (store) => {
569
+ await store.createBuild(sampleBuildInput('conditional-empty'))
570
+ const event = await store.appendIfCurrent(
571
+ 'conditional-empty',
572
+ 0,
573
+ sampleEventWrite('first'),
574
+ )
575
+ expect(event?.seq).toBe(1)
576
+ expect((await store.getEvents('conditional-empty')).map((entry) => entry.seq)).toEqual([
577
+ 1,
578
+ ])
579
+ })
580
+ })
581
+
582
+ test('a stale expected sequence appends nothing and leaves updatedAt untouched', async () => {
583
+ const clock = manualClock(CONTRACT_T0)
584
+ await withStore(factory, { clock }, async (store) => {
585
+ await store.createBuild(sampleBuildInput('conditional-stale'))
586
+ clock.advance(1000)
587
+ await store.appendIfCurrent('conditional-stale', 0, sampleEventWrite('winner'))
588
+ const before = await store.getBuild('conditional-stale')
589
+
590
+ clock.advance(1000)
591
+ expect(
592
+ await store.appendIfCurrent('conditional-stale', 0, sampleEventWrite('stale')),
593
+ ).toBeNull()
594
+ expect(await store.getBuild('conditional-stale')).toEqual(before)
595
+ expect((await store.getEvents('conditional-stale')).map((entry) => entry.seq)).toEqual([
596
+ 1,
597
+ ])
598
+ })
599
+ })
600
+
601
+ test('two contenders for one sequence produce one winner and preserve gapless appends', async () => {
602
+ await withStore(factory, undefined, async (store) => {
603
+ await store.createBuild(sampleBuildInput('conditional-race'))
604
+ const results = await Promise.all([
605
+ store.appendIfCurrent('conditional-race', 0, sampleEventWrite('a')),
606
+ store.appendIfCurrent('conditional-race', 0, sampleEventWrite('b')),
607
+ ])
608
+ expect(results.filter((result) => result !== null)).toHaveLength(1)
609
+ expect(results.filter((result) => result === null)).toHaveLength(1)
610
+
611
+ const next = await store.appendIfCurrent('conditional-race', 1, sampleEventWrite('next'))
612
+ expect(next?.seq).toBe(2)
613
+ expect((await store.getEvents('conditional-race')).map((entry) => entry.seq)).toEqual([
614
+ 1, 2,
615
+ ])
616
+ })
617
+ })
618
+
619
+ test('invalid candidates, invalid expected sequences, and unknown builds reject without writes', async () => {
620
+ await withStore(factory, undefined, async (store) => {
621
+ await store.createBuild(sampleBuildInput('conditional-invalid'))
622
+ await store.append('conditional-invalid', sampleEventWrite('existing'))
623
+ const invalid = {
624
+ actor: KERNEL,
625
+ type: 'no.such-type',
626
+ payload: {},
627
+ } as unknown as EventWrite
628
+
629
+ const invalidEvent = await store
630
+ .appendIfCurrent('conditional-invalid', 0, invalid)
631
+ .catch((error: unknown) => error)
632
+ expect(invalidEvent).toBeInstanceOf(EventValidationError)
633
+
634
+ const invalidSeq = await store
635
+ .appendIfCurrent('conditional-invalid', -1, sampleEventWrite())
636
+ .catch((error: unknown) => error)
637
+ expect(invalidSeq).toBeInstanceOf(Error)
638
+
639
+ const unknown = await store
640
+ .appendIfCurrent('ghost', 0, sampleEventWrite())
641
+ .catch((error: unknown) => error)
642
+ expect(unknown).toBeInstanceOf(Error)
643
+ expect((await store.getEvents('conditional-invalid')).map((entry) => entry.seq)).toEqual([
644
+ 1,
645
+ ])
646
+ })
647
+ })
648
+ })
649
+
650
+ describe('append validation (the enforced ontology, §8)', () => {
651
+ test('unknown event type throws EventValidationError and appends nothing', async () => {
652
+ await withStore(factory, undefined, async (store) => {
653
+ await store.createBuild(sampleBuildInput('val-type'))
654
+ await store.append('val-type', sampleEventWrite())
655
+ const bogus = {
656
+ actor: KERNEL,
657
+ type: 'no.such-type',
658
+ payload: {},
659
+ } as unknown as EventWrite
660
+ const err = await store.append('val-type', bogus).catch((e: unknown) => e)
661
+ expect(err).toBeInstanceOf(EventValidationError)
662
+ expect((await store.getEvents('val-type')).length).toBe(1)
663
+ })
664
+ })
665
+
666
+ test('malformed payload (missing field) throws EventValidationError and appends nothing', async () => {
667
+ await withStore(factory, undefined, async (store) => {
668
+ await store.createBuild(sampleBuildInput('val-missing'))
669
+ await store.append('val-missing', sampleEventWrite())
670
+ const missing = {
671
+ actor: DISPATCHER,
672
+ type: 'build.created',
673
+ payload: { repo: 'acme/rate-limiter' },
674
+ } as unknown as EventWrite
675
+ const err = await store.append('val-missing', missing).catch((e: unknown) => e)
676
+ expect(err).toBeInstanceOf(EventValidationError)
677
+ expect((await store.getEvents('val-missing')).length).toBe(1)
678
+ })
679
+ })
680
+
681
+ test('malformed payload (unknown extra key — payloads are strict) throws and appends nothing', async () => {
682
+ await withStore(factory, undefined, async (store) => {
683
+ await store.createBuild(sampleBuildInput('val-extra'))
684
+ await store.append('val-extra', sampleEventWrite())
685
+ const extra = {
686
+ actor: KERNEL,
687
+ type: 'workspace.released',
688
+ payload: { surprise: true },
689
+ } as unknown as EventWrite
690
+ const err = await store.append('val-extra', extra).catch((e: unknown) => e)
691
+ expect(err).toBeInstanceOf(EventValidationError)
692
+ expect((await store.getEvents('val-extra')).length).toBe(1)
693
+ })
694
+ })
695
+
696
+ test('disallowed actor kind (agent emitting pr.merged) throws and appends nothing', async () => {
697
+ await withStore(factory, undefined, async (store) => {
698
+ await store.createBuild(sampleBuildInput('val-actor'))
699
+ await store.append('val-actor', sampleEventWrite())
700
+ const err = await store
701
+ .append('val-actor', {
702
+ actor: agentActor('code-review', 's_9f2'),
703
+ type: 'pr.merged',
704
+ payload: { sha: 'abc1234' },
705
+ })
706
+ .catch((e: unknown) => e)
707
+ expect(err).toBeInstanceOf(EventValidationError)
708
+ expect((await store.getEvents('val-actor')).length).toBe(1)
709
+ })
710
+ })
711
+
712
+ test('only humans may append escalation answers, including bare retry', async () => {
713
+ await withStore(factory, undefined, async (store) => {
714
+ await store.createBuild(sampleBuildInput('val-dispatcher-answer'))
715
+ for (const payload of [
716
+ { id: 'esc_1', answer: 'continue', resolution: 'guidance' as const },
717
+ { id: 'esc_1', answer: 'retry', resolution: 'retry' as const },
718
+ ]) {
719
+ const err = await store
720
+ .append('val-dispatcher-answer', {
721
+ actor: DISPATCHER,
722
+ type: 'escalation.answered',
723
+ payload,
724
+ })
725
+ .catch((e: unknown) => e)
726
+ expect(err).toBeInstanceOf(EventValidationError)
727
+ expect(await store.getEvents('val-dispatcher-answer')).toEqual([])
728
+ }
729
+
730
+ await store.append('val-dispatcher-answer', {
731
+ actor: humanActor('operator'),
732
+ type: 'escalation.answered',
733
+ payload: { id: 'esc_1', answer: 'retry', resolution: 'retry' },
734
+ })
735
+ expect((await store.getEvents('val-dispatcher-answer')).map((e) => e.type)).toEqual([
736
+ 'escalation.answered',
737
+ ])
738
+ })
739
+ })
740
+ })
741
+
742
+ describe('getEvents', () => {
743
+ test('since is strictly greater-than, in seq order; default 0 = all (§7.2)', async () => {
744
+ await withStore(factory, undefined, async (store) => {
745
+ await store.createBuild(sampleBuildInput('since'))
746
+ await store.append('since', sampleEventWrite('one'))
747
+ await store.append('since', sampleEventWrite('two'))
748
+ await store.append('since', sampleEventWrite('three'))
749
+
750
+ const all = await store.getEvents('since')
751
+ expect(all.map((e) => e.seq)).toEqual([1, 2, 3])
752
+
753
+ const after1 = await store.getEvents('since', 1)
754
+ expect(after1.map((e) => e.seq)).toEqual([2, 3])
755
+
756
+ expect(await store.getEvents('since', 3)).toEqual([])
757
+ })
758
+ })
759
+
760
+ test('write operations on an unknown build reject', async () => {
761
+ await withStore(factory, undefined, async (store) => {
762
+ const appendErr = await store.append('ghost', sampleEventWrite()).catch((e: unknown) => e)
763
+ expect(appendErr).toBeInstanceOf(Error)
764
+ const putErr = await store
765
+ .putArtifact('ghost', { kind: 'plan', content: 'x' })
766
+ .catch((e: unknown) => e)
767
+ expect(putErr).toBeInstanceOf(Error)
768
+ const leaseErr = await store
769
+ .claimLease('ghost', 'runner-a', 1000)
770
+ .catch((e: unknown) => e)
771
+ expect(leaseErr).toBeInstanceOf(Error)
772
+ })
773
+ })
774
+ })
775
+
776
+ // The bounded wait on the event reads (AUT-334): the same clamp and
777
+ // early-return rules the stream and session reads honor, now on
778
+ // getEvents and getRepoEvents. Mirrors the stream wait suites below.
779
+ describe('bounded wait on event reads (AUT-334)', () => {
780
+ test('events past since return immediately, with or without a wait', async () => {
781
+ await withStore(factory, undefined, async (store) => {
782
+ await store.createBuild(sampleBuildInput('evt-wait-now'))
783
+ const event = await store.append('evt-wait-now', sampleEventWrite('one'))
784
+ const started = Date.now()
785
+ expect(await store.getEvents('evt-wait-now', 0, { waitSeconds: 5 })).toEqual([event])
786
+ expect(Date.now() - started).toBeLessThan(500)
787
+ })
788
+ })
789
+
790
+ test('a quiet stream returns empty no earlier than the bound', async () => {
791
+ await withStore(factory, undefined, async (store) => {
792
+ await store.createBuild(sampleBuildInput('evt-wait-quiet'))
793
+ const started = Date.now()
794
+ expect(await store.getEvents('evt-wait-quiet', 0, { waitSeconds: 1 })).toEqual([])
795
+ expect(Date.now() - started).toBeGreaterThanOrEqual(950)
796
+ })
797
+ })
798
+
799
+ test('a quiet stream without wait returns empty immediately (unchanged form)', async () => {
800
+ await withStore(factory, undefined, async (store) => {
801
+ await store.createBuild(sampleBuildInput('evt-wait-off'))
802
+ const started = Date.now()
803
+ expect(await store.getEvents('evt-wait-off', 0)).toEqual([])
804
+ expect(Date.now() - started).toBeLessThan(500)
805
+ })
806
+ })
807
+
808
+ test('an append during the wait returns early carrying that event', async () => {
809
+ await withStore(factory, undefined, async (store) => {
810
+ await store.createBuild(sampleBuildInput('evt-wait-wake'))
811
+ const pending = store.getEvents('evt-wait-wake', 0, { waitSeconds: 5 })
812
+ await Bun.sleep(100)
813
+ const event = await store.append('evt-wait-wake', sampleEventWrite('wake'))
814
+ expect(await pending).toEqual([event])
815
+ })
816
+ })
817
+
818
+ test('getRepoEvents honors the same bound, symmetrically', async () => {
819
+ await withStore(factory, undefined, async (store) => {
820
+ await store.ensureRepo('acme/evt-wait')
821
+ const started = Date.now()
822
+ const pending = store.getRepoEvents('acme/evt-wait', 0, { waitSeconds: 5 })
823
+ await Bun.sleep(100)
824
+ const event = await store.appendRepo('acme/evt-wait', harvestStartedWrite('h_wait'))
825
+ expect(await pending).toEqual([event])
826
+ // Events past since still answer immediately.
827
+ expect(Date.now() - started).toBeLessThan(5_000)
828
+ const immediateStart = Date.now()
829
+ expect(await store.getRepoEvents('acme/evt-wait', event.seq, { waitSeconds: 1 })).toEqual(
830
+ [],
831
+ )
832
+ // Nothing newer exists, so this is a held read answered at the
833
+ // bound — the symmetric quiet-stream assertion.
834
+ expect(Date.now() - immediateStart).toBeGreaterThanOrEqual(950)
835
+ })
836
+ })
837
+ })
838
+
839
+ // Abort on held reads (AUT-380): the optional signal cancels a held read
840
+ // before its bound — resolving with the current result, never rejecting —
841
+ // on every adapter uniformly. Explicit AbortController aborts only; no
842
+ // timing-flaky disconnect simulation.
843
+ describe('abort on held reads (AUT-380)', () => {
844
+ test('a pre-aborted signal answers all four held reads promptly and empty', async () => {
845
+ await withStore(factory, undefined, async (store) => {
846
+ await store.createBuild(sampleBuildInput('abort-pre'))
847
+ await store.ensureRepo('acme/abort-pre')
848
+ const session = await store.createSession({ repo: 'acme/abort-pre', operator: 'op' })
849
+ const stream = await store.createStream({ kind: 'build', build: 'abort-pre' }, 's')
850
+ const controller = new AbortController()
851
+ controller.abort()
852
+ const started = Date.now()
853
+ await expect(
854
+ store.getEvents('abort-pre', 0, { waitSeconds: 5, signal: controller.signal }),
855
+ ).resolves.toEqual([])
856
+ await expect(
857
+ store.getRepoEvents('acme/abort-pre', 0, { waitSeconds: 5, signal: controller.signal }),
858
+ ).resolves.toEqual([])
859
+ await expect(
860
+ store.getSessionEvents(session.id, 1, {
861
+ waitSeconds: 5,
862
+ signal: controller.signal,
863
+ }),
864
+ ).resolves.toEqual([])
865
+ const streamRead = await store.readStream(stream.id, {
866
+ waitSeconds: 5,
867
+ signal: controller.signal,
868
+ })
869
+ expect(streamRead.chunks).toEqual([])
870
+ expect(streamRead.status).toBe('open')
871
+ // Even Postgres's 1 s poll budget satisfies this: abort resolves
872
+ // the sleep, it does not wait for the next poll tick.
873
+ expect(Date.now() - started).toBeLessThan(500)
874
+ })
875
+ })
876
+
877
+ test('a mid-hold abort on a quiet session read resolves empty well under the bound', async () => {
878
+ await withStore(factory, undefined, async (store) => {
879
+ await store.ensureRepo('acme/abort-mid')
880
+ const session = await store.createSession({ repo: 'acme/abort-mid', operator: 'op' })
881
+ const controller = new AbortController()
882
+ // since=1: the session's own creation event is backlog; the hold
883
+ // must establish past it or there is nothing held to abort.
884
+ const pending = store.getSessionEvents(session.id, 1, {
885
+ waitSeconds: 10,
886
+ signal: controller.signal,
887
+ })
888
+ await Bun.sleep(100)
889
+ const started = Date.now()
890
+ controller.abort()
891
+ await expect(pending).resolves.toEqual([])
892
+ expect(Date.now() - started).toBeLessThan(5_000)
893
+ })
894
+ })
895
+ })
896
+
897
+ describe('artifacts', () => {
898
+ test('revisions are 0-based per kind (§6.3): first deposit rev 0, next rev 1', async () => {
899
+ await withStore(factory, undefined, async (store) => {
900
+ await store.createBuild(sampleBuildInput('revs'))
901
+ const p0 = await store.putArtifact('revs', { kind: 'plan', content: 'plan v0' })
902
+ const p1 = await store.putArtifact('revs', { kind: 'plan', content: 'plan v1' })
903
+ const s0 = await store.putArtifact('revs', { kind: 'spec', content: 'spec v0' })
904
+ expect(p0.revision).toBe(0)
905
+ expect(p1.revision).toBe(1)
906
+ expect(s0.revision).toBe(0)
907
+ })
908
+ })
909
+
910
+ test('same content twice yields the same blobRef (content-addressed, §7.1) but distinct revisions', async () => {
911
+ await withStore(factory, undefined, async (store) => {
912
+ await store.createBuild(sampleBuildInput('dedupe'))
913
+ const a = await store.putArtifact('dedupe', { kind: 'plan', content: 'identical' })
914
+ const b = await store.putArtifact('dedupe', { kind: 'plan', content: 'identical' })
915
+ expect(a.blobRef).toBe(contentHash(toBytes('identical')))
916
+ expect(b.blobRef).toBe(a.blobRef)
917
+ expect([a.revision, b.revision]).toEqual([0, 1])
918
+ })
919
+ })
920
+
921
+ test('metadata round-trips', async () => {
922
+ await withStore(factory, undefined, async (store) => {
923
+ await store.createBuild(sampleBuildInput('meta'))
924
+ const metadata = { phase: 'plan', round: 2, tags: ['a', 'b'] }
925
+ await store.putArtifact('meta', { kind: 'plan', content: 'p', metadata })
926
+ const artifact = await store.getArtifact('meta', 'plan')
927
+ expect(artifact?.meta.metadata).toEqual(metadata)
928
+ })
929
+ })
930
+
931
+ test('getArtifact: latest when rev omitted, pinned @rev, null when absent', async () => {
932
+ await withStore(factory, undefined, async (store) => {
933
+ await store.createBuild(sampleBuildInput('get'))
934
+ await store.putArtifact('get', { kind: 'plan', content: 'v0' })
935
+ await store.putArtifact('get', { kind: 'plan', content: 'v1' })
936
+
937
+ const latest = await store.getArtifact('get', 'plan')
938
+ expect(latest?.meta.revision).toBe(1)
939
+ expect(textContent(latest!)).toBe('v1')
940
+
941
+ const pinned = await store.getArtifact('get', 'plan', 0)
942
+ expect(pinned?.meta.revision).toBe(0)
943
+ expect(textContent(pinned!)).toBe('v0')
944
+
945
+ expect(await store.getArtifact('get', 'plan', 7)).toBeNull()
946
+ expect(await store.getArtifact('get', 'never-deposited')).toBeNull()
947
+ })
948
+ })
949
+
950
+ test('listArtifacts filters by kind and orders by (kind, revision)', async () => {
951
+ await withStore(factory, undefined, async (store) => {
952
+ await store.createBuild(sampleBuildInput('list'))
953
+ await store.putArtifact('list', { kind: 'plan', content: 'p0' })
954
+ await store.putArtifact('list', { kind: 'spec', content: 's0' })
955
+ await store.putArtifact('list', { kind: 'plan', content: 'p1' })
956
+
957
+ const all = await store.listArtifacts('list')
958
+ expect(all.map((m) => [m.kind, m.revision])).toEqual([
959
+ ['plan', 0],
960
+ ['plan', 1],
961
+ ['spec', 0],
962
+ ])
963
+
964
+ const plans = await store.listArtifacts('list', 'plan')
965
+ expect(plans.map((m) => [m.kind, m.revision])).toEqual([
966
+ ['plan', 0],
967
+ ['plan', 1],
968
+ ])
969
+ })
970
+ })
971
+ })
972
+
973
+ describe('artifact retention (dispatcher run/config family — store/retention.ts)', () => {
974
+ test('prunes the oldest revisions past maxRevisions for retention-family kinds only', async () => {
975
+ await withStore(factory, { retention: { maxRevisions: 2 } }, async (store) => {
976
+ await store.ensureRepo('acme/retention')
977
+
978
+ // Repo side, family kind, through the atomic deposit path the
979
+ // dispatcher cron uses (appendRepoWithArtifacts @ run-started).
980
+ for (const run of ['run-0', 'run-1', 'run-2']) {
981
+ const { artifacts } = await store.appendRepoWithArtifacts(
982
+ 'acme/retention',
983
+ [{ kind: 'dispatcher-effective-config', content: `cfg-${run}` }],
984
+ (deposited) => runStartedWrite(run, deposited),
985
+ )
986
+ expect(artifacts.map((a) => a.revision)).toEqual([Number(run.slice(-1))])
987
+ }
988
+ const revisions = (
989
+ await store.listRepoArtifacts('acme/retention', 'dispatcher-effective-config')
990
+ ).map((meta) => meta.revision)
991
+ // Pruned side: revision 0 is gone entirely…
992
+ expect(revisions).toEqual([1, 2])
993
+ expect(
994
+ await store.getRepoArtifact('acme/retention', 'dispatcher-effective-config', 0),
995
+ ).toBeNull()
996
+ // …preserved side: the surviving revisions still read, and the
997
+ // latest-by-default read (the current run's snapshot) still works.
998
+ expect(
999
+ (await store.getRepoArtifact('acme/retention', 'dispatcher-effective-config', 1))?.meta
1000
+ .revision,
1001
+ ).toBe(1)
1002
+ const latest = await store.getRepoArtifact(
1003
+ 'acme/retention',
1004
+ 'dispatcher-effective-config',
1005
+ )
1006
+ expect(latest?.meta.revision).toBe(2)
1007
+ expect(new TextDecoder().decode(latest!.content)).toBe('cfg-run-2')
1008
+
1009
+ // A non-family kind deposited by the same activity is never pruned.
1010
+ for (let i = 0; i < 3; i++) {
1011
+ await store.putRepoArtifact('acme/retention', {
1012
+ kind: 'harvest-scan',
1013
+ content: `s${i}`,
1014
+ })
1015
+ }
1016
+ expect(
1017
+ (await store.listRepoArtifacts('acme/retention', 'harvest-scan')).map(
1018
+ (meta) => meta.revision,
1019
+ ),
1020
+ ).toEqual([0, 1, 2])
1021
+
1022
+ // Build side, family kind (per-build snapshots).
1023
+ await store.createBuild(sampleBuildInput('retain-b'))
1024
+ for (let i = 0; i < 3; i++) {
1025
+ await store.putArtifact('retain-b', {
1026
+ kind: 'build-runner-effective-config',
1027
+ content: `bcfg-${i}`,
1028
+ })
1029
+ }
1030
+ expect(
1031
+ (await store.listArtifacts('retain-b', 'build-runner-effective-config')).map(
1032
+ (meta) => meta.revision,
1033
+ ),
1034
+ ).toEqual([1, 2])
1035
+ expect(await store.getArtifact('retain-b', 'build-runner-effective-config', 0)).toBeNull()
1036
+ expect(
1037
+ (await store.getArtifact('retain-b', 'build-runner-effective-config'))?.meta.revision,
1038
+ ).toBe(2)
1039
+ })
1040
+ })
1041
+
1042
+ test('the operator-notes kind is retention-managed at the same deposit path', async () => {
1043
+ await withStore(factory, { retention: { maxRevisions: 2 } }, async (store) => {
1044
+ await store.ensureRepo('acme/retention')
1045
+ // Three deposits of the agent notes kind prune the oldest, exactly
1046
+ // like the dispatcher family: the newest 2 survive, and the
1047
+ // latest-by-default read (what notes.read serves) still works.
1048
+ for (let i = 0; i < 3; i++) {
1049
+ await store.putRepoArtifact('acme/retention', {
1050
+ kind: 'operator-notes',
1051
+ content: `notes-${i}`,
1052
+ metadata: { user: 'Ada' },
1053
+ })
1054
+ }
1055
+ const revisions = (await store.listRepoArtifacts('acme/retention', 'operator-notes')).map(
1056
+ (meta) => meta.revision,
1057
+ )
1058
+ expect(revisions).toEqual([1, 2])
1059
+ expect(await store.getRepoArtifact('acme/retention', 'operator-notes', 0)).toBeNull()
1060
+ const latest = await store.getRepoArtifact('acme/retention', 'operator-notes')
1061
+ expect(latest?.meta.revision).toBe(2)
1062
+ expect(new TextDecoder().decode(latest!.content)).toBe('notes-2')
1063
+ // The dispatcher family's documented bound is untouched by the
1064
+ // operator kind: a family deposit still prunes its own kind alone.
1065
+ const { artifacts } = await store.appendRepoWithArtifacts(
1066
+ 'acme/retention',
1067
+ [{ kind: 'dispatcher-effective-config', content: 'cfg' }],
1068
+ (deposited) => runStartedWrite('retention-notes-run', deposited),
1069
+ )
1070
+ expect(artifacts.map((meta) => meta.revision)).toEqual([0])
1071
+ expect(
1072
+ (await store.listRepoArtifacts('acme/retention', 'operator-notes')).map(
1073
+ (meta) => meta.revision,
1074
+ ),
1075
+ ).toEqual([1, 2])
1076
+ })
1077
+ })
1078
+ })
1079
+
1080
+ describe('validation-before-prune ordering (AUT-322)', () => {
1081
+ test('a same-kind batch larger than the retention bound validates the event before pruning: the newest sibling survives with its event', async () => {
1082
+ await withStore(factory, { retention: { maxRevisions: 2 } }, async (store) => {
1083
+ await store.createBuild(sampleBuildInput('vbp-batch'))
1084
+ const { event, artifacts } = await store.appendWithArtifacts(
1085
+ 'vbp-batch',
1086
+ [
1087
+ { kind: 'build-runner-effective-config', content: 'cfg-0' },
1088
+ { kind: 'build-runner-effective-config', content: 'cfg-1' },
1089
+ { kind: 'build-runner-effective-config', content: 'cfg-2' },
1090
+ ],
1091
+ // The payload references the batch's last assigned revision;
1092
+ // batch kinds need not appear in the payload.
1093
+ (deposited) => planCompletedWrite(deposited.at(-1)!.revision),
1094
+ )
1095
+ // Validation provably ran: the event was appended and returned.
1096
+ expect(event.seq).toBe(1)
1097
+ expect(artifacts.map((meta) => meta.revision)).toEqual([0, 1, 2])
1098
+
1099
+ // Post-prune state is exactly the retention policy's answer over
1100
+ // the full post-batch revision set: [1, 2] retained, 0 pruned.
1101
+ const revisions = (
1102
+ await store.listArtifacts('vbp-batch', 'build-runner-effective-config')
1103
+ ).map((meta) => meta.revision)
1104
+ expect(revisions).toEqual([1, 2])
1105
+ expect(
1106
+ await store.getArtifact('vbp-batch', 'build-runner-effective-config', 0),
1107
+ ).toBeNull()
1108
+ expect(
1109
+ (await store.getArtifact('vbp-batch', 'build-runner-effective-config', 1))?.meta
1110
+ .revision,
1111
+ ).toBe(1)
1112
+ expect(
1113
+ (await store.getArtifact('vbp-batch', 'build-runner-effective-config'))?.meta.revision,
1114
+ ).toBe(2)
1115
+ })
1116
+ })
1117
+
1118
+ test('repo-side mirror: appendRepoWithArtifacts validates before pruning a same-kind batch', async () => {
1119
+ await withStore(factory, { retention: { maxRevisions: 2 } }, async (store) => {
1120
+ await store.ensureRepo('acme/vbp')
1121
+ const { event, artifacts } = await store.appendRepoWithArtifacts(
1122
+ 'acme/vbp',
1123
+ [
1124
+ { kind: 'dispatcher-effective-config', content: 'cfg-0' },
1125
+ { kind: 'dispatcher-effective-config', content: 'cfg-1' },
1126
+ { kind: 'dispatcher-effective-config', content: 'cfg-2' },
1127
+ ],
1128
+ (deposited) => runStartedWrite('vbp-run', deposited),
1129
+ )
1130
+ expect(event.seq).toBe(1)
1131
+ expect(artifacts.map((meta) => meta.revision)).toEqual([0, 1, 2])
1132
+ const revisions = (
1133
+ await store.listRepoArtifacts('acme/vbp', 'dispatcher-effective-config')
1134
+ ).map((meta) => meta.revision)
1135
+ expect(revisions).toEqual([1, 2])
1136
+ expect(
1137
+ await store.getRepoArtifact('acme/vbp', 'dispatcher-effective-config', 0),
1138
+ ).toBeNull()
1139
+ const latest = await store.getRepoArtifact('acme/vbp', 'dispatcher-effective-config')
1140
+ expect(latest?.meta.revision).toBe(2)
1141
+ expect(new TextDecoder().decode(latest!.content)).toBe('cfg-2')
1142
+ })
1143
+ })
1144
+
1145
+ test('an invalid batch event leaves exactly the pre-call state — no prune outruns validation', async () => {
1146
+ await withStore(factory, { retention: { maxRevisions: 2 } }, async (store) => {
1147
+ await store.createBuild(sampleBuildInput('vbp-invalid'))
1148
+ await store.putArtifact('vbp-invalid', {
1149
+ kind: 'build-runner-effective-config',
1150
+ content: 'pre-existing',
1151
+ })
1152
+
1153
+ const bogus = {
1154
+ actor: KERNEL,
1155
+ type: 'no.such-type',
1156
+ payload: {},
1157
+ } as unknown as EventWrite
1158
+ const err = await store
1159
+ .appendWithArtifacts(
1160
+ 'vbp-invalid',
1161
+ [
1162
+ { kind: 'build-runner-effective-config', content: 'cfg-0' },
1163
+ { kind: 'build-runner-effective-config', content: 'cfg-1' },
1164
+ { kind: 'build-runner-effective-config', content: 'cfg-2' },
1165
+ ],
1166
+ () => bogus,
1167
+ )
1168
+ .catch((e: unknown) => e)
1169
+ expect(err).toBeInstanceOf(EventValidationError)
1170
+
1171
+ // Exactly the pre-call state: the pre-existing revision 0 — a
1172
+ // would-be prune victim (the post-batch set [0..3] prunes [0, 1]) —
1173
+ // was NOT deleted before validation failed, no batch deposit
1174
+ // landed, and the event log is empty. An adapter that pruned
1175
+ // before validating and could not roll back loses revision 0 here.
1176
+ const revisions = (
1177
+ await store.listArtifacts('vbp-invalid', 'build-runner-effective-config')
1178
+ ).map((meta) => meta.revision)
1179
+ expect(revisions).toEqual([0])
1180
+ const survivor = await store.getArtifact(
1181
+ 'vbp-invalid',
1182
+ 'build-runner-effective-config',
1183
+ 0,
1184
+ )
1185
+ expect(new TextDecoder().decode(survivor!.content)).toBe('pre-existing')
1186
+ expect(await store.getEvents('vbp-invalid')).toEqual([])
1187
+ })
1188
+ })
1189
+ })
1190
+
1191
+ describe('appendWithArtifacts (atomic deposits, D6 — §8.5)', () => {
1192
+ test('success: the event payload references deposited revs via makeEvent(deposited)', async () => {
1193
+ await withStore(factory, undefined, async (store) => {
1194
+ await store.createBuild(sampleBuildInput('bundle'))
1195
+ const { event, artifacts } = await store.appendWithArtifacts(
1196
+ 'bundle',
1197
+ [
1198
+ { kind: 'plan', content: 'the plan', metadata: { round: 1 } },
1199
+ { kind: 'transcript', content: 'transcript body' },
1200
+ ],
1201
+ (deposited) => planCompletedWrite(deposited[0]!.revision),
1202
+ )
1203
+ expect(artifacts.map((m) => [m.kind, m.revision])).toEqual([
1204
+ ['plan', 0],
1205
+ ['transcript', 0],
1206
+ ])
1207
+ expect(event.type).toBe('plan.completed')
1208
+ expect(event.payload).toEqual({
1209
+ round: 1,
1210
+ artifact: { kind: 'plan', rev: 0 },
1211
+ verifySteps: ['types', 'unit'],
1212
+ })
1213
+
1214
+ const log = await store.getEvents('bundle')
1215
+ expect(log.map((e) => e.type)).toEqual(['plan.completed'])
1216
+ const plan = await store.getArtifact('bundle', 'plan')
1217
+ expect(textContent(plan!)).toBe('the plan')
1218
+ expect(plan?.meta.metadata).toEqual({ round: 1 })
1219
+ })
1220
+ })
1221
+
1222
+ test('failure: an invalid artifact input mid-bundle persists nothing — no orphan deposits', async () => {
1223
+ await withStore(factory, undefined, async (store) => {
1224
+ await store.createBuild(sampleBuildInput('bundle-bad-input'))
1225
+ const err = await store
1226
+ .appendWithArtifacts(
1227
+ 'bundle-bad-input',
1228
+ [
1229
+ { kind: 'plan', content: 'the plan' },
1230
+ { kind: '', content: 'kindless' }, // invalid: kind is required
1231
+ ],
1232
+ (deposited) => planCompletedWrite(deposited[0]!.revision),
1233
+ )
1234
+ .catch((e: unknown) => e)
1235
+ expect(err).toBeInstanceOf(Error)
1236
+ // D6: "no state where an artifact exists without its event" — the
1237
+ // first input must not survive its bundle-mate's rejection.
1238
+ expect(await store.listArtifacts('bundle-bad-input')).toEqual([])
1239
+ expect(await store.getEvents('bundle-bad-input')).toEqual([])
1240
+ })
1241
+ })
1242
+
1243
+ test('failure: an invalid event rolls back the whole deposit and propagates the error', async () => {
1244
+ await withStore(factory, undefined, async (store) => {
1245
+ await store.createBuild(sampleBuildInput('rollback'))
1246
+ await store.putArtifact('rollback', { kind: 'plan', content: 'original plan' })
1247
+ await store.append('rollback', sampleEventWrite('pre-existing'))
1248
+
1249
+ const err = await store
1250
+ .appendWithArtifacts(
1251
+ 'rollback',
1252
+ [
1253
+ { kind: 'plan', content: 'round 2 plan' },
1254
+ { kind: 'plan', content: 'round 3 plan' },
1255
+ ],
1256
+ // kernel may not emit plan.completed (§15.3) → EventValidationError
1257
+ () => ({ ...planCompletedWrite(1, 2), actor: KERNEL }),
1258
+ )
1259
+ .catch((e: unknown) => e)
1260
+ expect(err).toBeInstanceOf(EventValidationError)
1261
+
1262
+ const plans = await store.listArtifacts('rollback', 'plan')
1263
+ expect(plans.map((m) => m.revision)).toEqual([0])
1264
+ const latest = await store.getArtifact('rollback', 'plan')
1265
+ expect(textContent(latest!)).toBe('original plan')
1266
+ expect((await store.getEvents('rollback')).length).toBe(1)
1267
+ })
1268
+ })
1269
+ })
1270
+
1271
+ describe('leases (§15.2.6, §7.4 — mutable liveness, never events)', () => {
1272
+ test('claim on an unheld lease succeeds and is visible on the record', async () => {
1273
+ const clock = manualClock(CONTRACT_T0)
1274
+ await withStore(factory, { clock }, async (store) => {
1275
+ await store.createBuild(sampleBuildInput('lease-claim'))
1276
+ expect(await store.claimLease('lease-claim', 'runner-a', 60_000)).toBe(true)
1277
+ const record = await store.getBuild('lease-claim')
1278
+ expect(record?.lease).toEqual({
1279
+ holder: 'runner-a',
1280
+ expiresAt: atT0(60_000),
1281
+ })
1282
+ })
1283
+ })
1284
+
1285
+ test('a second holder is rejected while the lease is unexpired', async () => {
1286
+ const clock = manualClock(CONTRACT_T0)
1287
+ await withStore(factory, { clock }, async (store) => {
1288
+ await store.createBuild(sampleBuildInput('lease-contend'))
1289
+ expect(await store.claimLease('lease-contend', 'runner-a', 1000)).toBe(true)
1290
+ clock.advance(999)
1291
+ expect(await store.claimLease('lease-contend', 'runner-b', 1000)).toBe(false)
1292
+ expect((await store.getBuild('lease-contend'))?.lease?.holder).toBe('runner-a')
1293
+ })
1294
+ })
1295
+
1296
+ test('the same holder renews, extending expiry', async () => {
1297
+ const clock = manualClock(CONTRACT_T0)
1298
+ await withStore(factory, { clock }, async (store) => {
1299
+ await store.createBuild(sampleBuildInput('lease-renew'))
1300
+ expect(await store.claimLease('lease-renew', 'runner-a', 1000)).toBe(true)
1301
+ clock.advance(600)
1302
+ expect(await store.claimLease('lease-renew', 'runner-a', 1000)).toBe(true)
1303
+ expect((await store.getBuild('lease-renew'))?.lease?.expiresAt).toBe(atT0(1600))
1304
+ // t=1300: past the original expiry (t=1000) but inside the renewal.
1305
+ clock.advance(700)
1306
+ expect(await store.claimLease('lease-renew', 'runner-b', 1000)).toBe(false)
1307
+ // t=1700: past the renewed expiry (t=1600).
1308
+ clock.advance(400)
1309
+ expect(await store.claimLease('lease-renew', 'runner-b', 1000)).toBe(true)
1310
+ })
1311
+ })
1312
+
1313
+ test('an expired lease is claimable by a new holder (dead sandbox takeover, §7.4)', async () => {
1314
+ const clock = manualClock(CONTRACT_T0)
1315
+ await withStore(factory, { clock }, async (store) => {
1316
+ await store.createBuild(sampleBuildInput('lease-expire'))
1317
+ expect(await store.claimLease('lease-expire', 'runner-a', 1000)).toBe(true)
1318
+ clock.advance(1001)
1319
+ expect(await store.claimLease('lease-expire', 'runner-b', 1000)).toBe(true)
1320
+ expect((await store.getBuild('lease-expire'))?.lease?.holder).toBe('runner-b')
1321
+ })
1322
+ })
1323
+
1324
+ test('heartbeat is true only for the current unexpired holder, and extends expiry', async () => {
1325
+ const clock = manualClock(CONTRACT_T0)
1326
+ await withStore(factory, { clock }, async (store) => {
1327
+ await store.createBuild(sampleBuildInput('lease-beat'))
1328
+ expect(await store.heartbeat('lease-beat', 'runner-a')).toBe(false) // no lease
1329
+ expect(await store.claimLease('lease-beat', 'runner-a', 1000)).toBe(true)
1330
+ expect(await store.heartbeat('lease-beat', 'runner-b')).toBe(false) // not the holder
1331
+
1332
+ clock.advance(800)
1333
+ expect(await store.heartbeat('lease-beat', 'runner-a')).toBe(true)
1334
+ const record = await store.getBuild('lease-beat')
1335
+ expect(record?.heartbeatAt).toBe(atT0(800))
1336
+ expect(record?.lease?.expiresAt).toBe(atT0(1800))
1337
+
1338
+ // t=1700: without the heartbeat the lease would have died at t=1000.
1339
+ clock.advance(900)
1340
+ expect(await store.claimLease('lease-beat', 'runner-b', 1000)).toBe(false)
1341
+
1342
+ // t=1900: past the extended expiry — the holder's heartbeat is dead too.
1343
+ clock.advance(200)
1344
+ expect(await store.heartbeat('lease-beat', 'runner-a')).toBe(false)
1345
+ expect(await store.claimLease('lease-beat', 'runner-b', 1000)).toBe(true)
1346
+ })
1347
+ })
1348
+
1349
+ test('releaseLease by the holder frees it; by a non-holder is a no-op', async () => {
1350
+ await withStore(factory, undefined, async (store) => {
1351
+ await store.createBuild(sampleBuildInput('lease-release'))
1352
+ expect(await store.claimLease('lease-release', 'runner-a', 60_000)).toBe(true)
1353
+
1354
+ await store.releaseLease('lease-release', 'runner-b') // no-op
1355
+ expect(await store.claimLease('lease-release', 'runner-c', 1000)).toBe(false)
1356
+
1357
+ await store.releaseLease('lease-release', 'runner-a')
1358
+ expect((await store.getBuild('lease-release'))?.lease).toBeUndefined()
1359
+ expect(await store.claimLease('lease-release', 'runner-c', 1000)).toBe(true)
1360
+ })
1361
+ })
1362
+ })
1363
+
1364
+ describe('build-scoped handles', () => {
1365
+ test('permit own-build stream, artifact, lease, and subscription operations', async () => {
1366
+ await withStore(factory, undefined, async (store) => {
1367
+ await store.createBuild(sampleBuildInput('scope-own'))
1368
+ const scoped = store.scopeBuild('scope-own')
1369
+ expect(scoped.buildScope).toBe('scope-own')
1370
+ expect(await scoped.getBuild('scope-own')).not.toBeNull()
1371
+ await scoped.append('scope-own', sampleEventWrite())
1372
+ await scoped.putArtifact('scope-own', { kind: 'notes', content: 'owned' })
1373
+ expect(textContent((await scoped.getArtifact('scope-own', 'notes'))!)).toBe('owned')
1374
+ expect(await scoped.claimLease('scope-own', 'runner', 1000)).toBe(true)
1375
+ expect(await scoped.heartbeat('scope-own', 'runner')).toBe(true)
1376
+ await scoped.releaseLease('scope-own', 'runner')
1377
+ const unsubscribe = scoped.subscribe('scope-own', { pollMs: 5 }, () => {})
1378
+ unsubscribe()
1379
+ expect(scoped.scopeBuild('scope-own')).toBe(scoped)
1380
+ })
1381
+ })
1382
+
1383
+ test('reject foreign-build, collection/admin, nested foreign scope, and repository access', async () => {
1384
+ await withStore(factory, undefined, async (store) => {
1385
+ await store.createBuild(sampleBuildInput('scope-a'))
1386
+ await store.createBuild(sampleBuildInput('scope-b'))
1387
+ const scoped = store.scopeBuild('scope-a')
1388
+ expect(() => scoped.scopeBuild('scope-b')).toThrow(/build-scoped store/)
1389
+ await expect(scoped.getBuild('scope-b')).rejects.toThrow(/build-scoped store/)
1390
+ await expect(scoped.append('scope-b', sampleEventWrite())).rejects.toThrow(
1391
+ /build-scoped store/,
1392
+ )
1393
+ expect(() => scoped.subscribe('scope-b', {}, () => {})).toThrow(/build-scoped store/)
1394
+ await expect(scoped.listBuilds()).rejects.toThrow(/build-scoped store/)
1395
+ await expect(scoped.createBuild(sampleBuildInput('scope-c'))).rejects.toThrow(
1396
+ /build-scoped store/,
1397
+ )
1398
+ await expect(scoped.ensureRepo('acme/rate-limiter')).rejects.toThrow(/build-scoped store/)
1399
+ await expect(scoped.getRepoEvents('acme/rate-limiter')).rejects.toThrow(
1400
+ /build-scoped store/,
1401
+ )
1402
+ await expect(scoped.close()).rejects.toThrow(/build-scoped store/)
1403
+ })
1404
+ })
1405
+ })
1406
+
1407
+ describe('subscribe (§7.2 — polling delivery)', () => {
1408
+ test('delivers appended events in order, each exactly once', async () => {
1409
+ await withStore(factory, undefined, async (store) => {
1410
+ await store.createBuild(sampleBuildInput('sub-order'))
1411
+ const received: number[] = []
1412
+ const unsubscribe = store.subscribe('sub-order', { pollMs: 10 }, (event) => {
1413
+ received.push(event.seq)
1414
+ })
1415
+ await store.append('sub-order', sampleEventWrite('one'))
1416
+ await waitForDelivery(() => received.includes(1), 'the first event')
1417
+ await store.append('sub-order', sampleEventWrite('two'))
1418
+ await store.append('sub-order', sampleEventWrite('three'))
1419
+ await waitForDelivery(() => received.length >= 3, 'all three events')
1420
+ unsubscribe()
1421
+ // Settle past unsubscribe: a duplicate delivery would still show
1422
+ // up in the exact-array pin below.
1423
+ await Bun.sleep(50)
1424
+ expect(received).toEqual([1, 2, 3])
1425
+ })
1426
+ }, 30_000)
1427
+
1428
+ test('fromSeq skips earlier events', async () => {
1429
+ await withStore(factory, undefined, async (store) => {
1430
+ await store.createBuild(sampleBuildInput('sub-from'))
1431
+ await store.append('sub-from', sampleEventWrite('one'))
1432
+ await store.append('sub-from', sampleEventWrite('two'))
1433
+ const received: number[] = []
1434
+ const unsubscribe = store.subscribe('sub-from', { fromSeq: 1, pollMs: 10 }, (event) =>
1435
+ received.push(event.seq),
1436
+ )
1437
+ await store.append('sub-from', sampleEventWrite('three'))
1438
+ await waitForDelivery(() => received.length >= 2, 'the events from seq 2')
1439
+ unsubscribe()
1440
+ await Bun.sleep(50)
1441
+ expect(received).toEqual([2, 3])
1442
+ })
1443
+ }, 30_000)
1444
+
1445
+ test('unsubscribe stops delivery', async () => {
1446
+ await withStore(factory, undefined, async (store) => {
1447
+ await store.createBuild(sampleBuildInput('sub-stop'))
1448
+ const received: number[] = []
1449
+ const unsubscribe = store.subscribe('sub-stop', { pollMs: 10 }, (event) => {
1450
+ received.push(event.seq)
1451
+ })
1452
+ await store.append('sub-stop', sampleEventWrite('one'))
1453
+ await waitForDelivery(() => received.length >= 1, 'the first event')
1454
+ unsubscribe()
1455
+ await store.append('sub-stop', sampleEventWrite('two'))
1456
+ // Generous observation window: a still-subscribed client would
1457
+ // have completed ≥ 50 poll cycles at pollMs=10 (and on the
1458
+ // held-read remote path the append-wake plus round-trip is well
1459
+ // under 1 s), so a late delivery cannot hide inside it.
1460
+ await Bun.sleep(1_000)
1461
+ expect(received).toEqual([1])
1462
+ })
1463
+ }, 30_000)
1464
+ })
1465
+
1466
+ describe('streams (SPEC §7.6 — the third primitive)', () => {
1467
+ test('create assigns the id, literal format, open status, and clock createdAt; get and list round-trip', async () => {
1468
+ const clock = manualClock(CONTRACT_T0)
1469
+ await withStore(factory, { clock }, async (store) => {
1470
+ await store.createBuild(sampleBuildInput('st-create'))
1471
+ const record = await store.createStream(
1472
+ { kind: 'build', build: 'st-create' },
1473
+ 'implement turn',
1474
+ )
1475
+ expect(record.id).toMatch(/^st_/)
1476
+ expect(record.scope).toEqual({ kind: 'build', build: 'st-create' })
1477
+ expect(record.label).toBe('implement turn')
1478
+ expect(record.format).toBe('ai-ui-message-stream/v1')
1479
+ expect(record.status).toBe('open')
1480
+ expect(record.createdAt).toBe(CONTRACT_T0)
1481
+ expect(record.closedAt).toBeUndefined()
1482
+ expect(record.outcome).toBeUndefined()
1483
+ expect(record.artifact).toBeUndefined()
1484
+ expect(await store.getStream(record.id)).toEqual(record)
1485
+ expect(await store.getStream('st_unknown')).toBeNull()
1486
+ expect(await store.listStreams({ kind: 'build', build: 'st-create' })).toEqual([record])
1487
+ await store.createBuild(sampleBuildInput('st-empty'))
1488
+ expect(await store.listStreams({ kind: 'build', build: 'st-empty' })).toEqual([])
1489
+ })
1490
+ })
1491
+
1492
+ test('listStreams pins the same-timestamp tiebreak by creation order and isolates by scope', async () => {
1493
+ // The pinned tiebreak (store/types.ts): same-millisecond streams
1494
+ // order by the store-assigned creation sequence — creation order,
1495
+ // never the random st_ id. The clock is injected and NOT advanced for
1496
+ // the first five creations, so they share one timestamp and the
1497
+ // returned order must be creation order on every adapter.
1498
+ const clock = manualClock(CONTRACT_T0)
1499
+ await withStore(factory, { clock }, async (store) => {
1500
+ await store.createBuild(sampleBuildInput('st-tie-a'))
1501
+ await store.createBuild(sampleBuildInput('st-tie-b'))
1502
+ await store.ensureRepo('acme/rate-limiter')
1503
+ const a = await store.createStream({ kind: 'build', build: 'st-tie-a' }, 'first')
1504
+ const a2 = await store.createStream({ kind: 'build', build: 'st-tie-a' }, 'second')
1505
+ const a3 = await store.createStream({ kind: 'build', build: 'st-tie-a' }, 'third')
1506
+ const b = await store.createStream({ kind: 'build', build: 'st-tie-b' }, 'other build')
1507
+ const r = await store.createStream(
1508
+ { kind: 'repo', repo: 'acme/rate-limiter' },
1509
+ 'repo journal',
1510
+ )
1511
+ expect([a.createdAt, a2.createdAt, a3.createdAt, b.createdAt, r.createdAt]).toEqual([
1512
+ CONTRACT_T0,
1513
+ CONTRACT_T0,
1514
+ CONTRACT_T0,
1515
+ CONTRACT_T0,
1516
+ CONTRACT_T0,
1517
+ ])
1518
+ expect(
1519
+ (await store.listStreams({ kind: 'build', build: 'st-tie-a' })).map((s) => s.id),
1520
+ ).toEqual([a.id, a2.id, a3.id])
1521
+ expect(
1522
+ (await store.listStreams({ kind: 'build', build: 'st-tie-b' })).map((s) => s.id),
1523
+ ).toEqual([b.id])
1524
+ expect(
1525
+ (await store.listStreams({ kind: 'repo', repo: 'acme/rate-limiter' })).map((s) => s.id),
1526
+ ).toEqual([r.id])
1527
+ // Distinct-timestamp ordering is unchanged.
1528
+ clock.advance(1)
1529
+ const a4 = await store.createStream({ kind: 'build', build: 'st-tie-a' }, 'fourth')
1530
+ clock.advance(1)
1531
+ const a5 = await store.createStream({ kind: 'build', build: 'st-tie-a' }, 'fifth')
1532
+ expect(a4.createdAt > a3.createdAt).toBe(true)
1533
+ expect(a5.createdAt > a4.createdAt).toBe(true)
1534
+ expect(
1535
+ (await store.listStreams({ kind: 'build', build: 'st-tie-a' })).map((s) => s.id),
1536
+ ).toEqual([a.id, a2.id, a3.id, a4.id, a5.id])
1537
+ })
1538
+ })
1539
+
1540
+ test('append assigns per-stream sequences from 1, independent across streams, with clock ts and exact parts', async () => {
1541
+ const clock = manualClock(CONTRACT_T0)
1542
+ await withStore(factory, { clock }, async (store) => {
1543
+ await store.createBuild(sampleBuildInput('st-append'))
1544
+ const a = await store.createStream({ kind: 'build', build: 'st-append' }, 'a')
1545
+ const b = await store.createStream({ kind: 'build', build: 'st-append' }, 'b')
1546
+ clock.advance(1000)
1547
+ const parts: StreamPart[] = [
1548
+ { type: 'text-delta', id: 't', delta: 'hello' },
1549
+ { type: 'data-probe', data: { nested: true, extra: 'keys survive' } },
1550
+ ]
1551
+ const c1 = await store.appendStreamParts(a.id, parts)
1552
+ clock.advance(1000)
1553
+ const c2 = await store.appendStreamParts(a.id, [{ type: 'text-end', id: 't' }])
1554
+ const b1 = await store.appendStreamParts(b.id, [{ type: 'start', messageId: 'm' }])
1555
+ expect([c1.seq, c2.seq]).toEqual([1, 2])
1556
+ expect(b1.seq).toBe(1)
1557
+ expect(c1.stream).toBe(a.id)
1558
+ expect(c1.ts).toBe(atT0(1000))
1559
+ expect(c2.ts).toBe(atT0(2000))
1560
+ expect(c1.parts).toEqual(parts)
1561
+ const read = await store.readStream(a.id)
1562
+ expect(read.chunks.map((chunk) => chunk.seq)).toEqual([1, 2])
1563
+ expect(read.chunks[0]?.parts).toEqual(parts)
1564
+ expect(read.status).toBe('open')
1565
+ expect(read.outcome).toBeUndefined()
1566
+ expect(read.artifact).toBeUndefined()
1567
+ })
1568
+ })
1569
+
1570
+ test('append validation rejects empty batches, non-object parts, missing and empty type, and unknown streams — writing nothing', async () => {
1571
+ await withStore(factory, undefined, async (store) => {
1572
+ await store.createBuild(sampleBuildInput('st-validate'))
1573
+ const stream = await store.createStream({ kind: 'build', build: 'st-validate' }, 'v')
1574
+ for (const bad of [
1575
+ [] as StreamPart[],
1576
+ ['not an object' as unknown as StreamPart],
1577
+ [{ delta: 'no type' } as unknown as StreamPart],
1578
+ [{ type: '' } as unknown as StreamPart],
1579
+ ]) {
1580
+ const err = await store.appendStreamParts(stream.id, bad).catch((e: unknown) => e)
1581
+ expect(err).toBeInstanceOf(Error)
1582
+ }
1583
+ const unknown = await store
1584
+ .appendStreamParts('st_ghost', [{ type: 'text-delta', id: 't', delta: 'x' }])
1585
+ .catch((e: unknown) => e)
1586
+ expect(unknown).toBeInstanceOf(Error)
1587
+ expect((await store.readStream(stream.id)).chunks).toEqual([])
1588
+ })
1589
+ })
1590
+
1591
+ test('a batch whose serialized size exceeds the ceiling rejects with StreamBatchTooLargeError and writes nothing', async () => {
1592
+ await withStore(factory, undefined, async (store) => {
1593
+ await store.createBuild(sampleBuildInput('st-ceiling'))
1594
+ const stream = await store.createStream({ kind: 'build', build: 'st-ceiling' }, 'big')
1595
+ const oversized: StreamPart[] = [
1596
+ { type: 'text-delta', id: 't', delta: 'x'.repeat(1_048_600) },
1597
+ ]
1598
+ const err = await store.appendStreamParts(stream.id, oversized).catch((e: unknown) => e)
1599
+ expect(err).toBeInstanceOf(StreamBatchTooLargeError)
1600
+ expect((err as Error).message).toContain('1048576')
1601
+ expect((await store.readStream(stream.id)).chunks).toEqual([])
1602
+ // Just under the ceiling appends fine.
1603
+ await store.appendStreamParts(stream.id, [
1604
+ { type: 'text-delta', id: 't', delta: 'x'.repeat(1_000) },
1605
+ ])
1606
+ expect((await store.readStream(stream.id)).chunks).toHaveLength(1)
1607
+ })
1608
+ })
1609
+
1610
+ test('read since is strictly greater-than, in order; cursor resumes deliver exactly-once', async () => {
1611
+ await withStore(factory, undefined, async (store) => {
1612
+ await store.createBuild(sampleBuildInput('st-since'))
1613
+ const stream = await store.createStream({ kind: 'build', build: 'st-since' }, 's')
1614
+ for (const n of [1, 2, 3]) {
1615
+ await store.appendStreamParts(stream.id, [
1616
+ { type: 'text-delta', id: 't', delta: String(n) },
1617
+ ])
1618
+ }
1619
+ expect((await store.readStream(stream.id)).chunks.map((c) => c.seq)).toEqual([1, 2, 3])
1620
+ expect(
1621
+ (await store.readStream(stream.id, { since: 1 })).chunks.map((c) => c.seq),
1622
+ ).toEqual([2, 3])
1623
+ expect(await store.readStream(stream.id, { since: 3 })).toEqual({
1624
+ chunks: [],
1625
+ status: 'open',
1626
+ })
1627
+ // Resuming from the last processed cursor never re-delivers: each
1628
+ // chunk after the cursor arrives exactly once, in order.
1629
+ const resumed = await store.readStream(stream.id, { since: 2 })
1630
+ expect(resumed.chunks.map((c) => c.seq)).toEqual([3])
1631
+ const done = await store.readStream(stream.id, { since: 3 })
1632
+ expect(done.chunks).toEqual([])
1633
+ })
1634
+ })
1635
+
1636
+ test('bounded wait returns empty no earlier than the bound when nothing arrives', async () => {
1637
+ await withStore(factory, undefined, async (store) => {
1638
+ await store.createBuild(sampleBuildInput('st-wait'))
1639
+ const stream = await store.createStream({ kind: 'build', build: 'st-wait' }, 'w')
1640
+ const started = Date.now()
1641
+ const read = await store.readStream(stream.id, { waitSeconds: 1 })
1642
+ expect(read.chunks).toEqual([])
1643
+ expect(read.status).toBe('open')
1644
+ expect(Date.now() - started).toBeGreaterThanOrEqual(950)
1645
+ })
1646
+ })
1647
+
1648
+ test('bounded wait returns early when a chunk is appended during the wait', async () => {
1649
+ await withStore(factory, undefined, async (store) => {
1650
+ await store.createBuild(sampleBuildInput('st-wake'))
1651
+ const stream = await store.createStream({ kind: 'build', build: 'st-wake' }, 'w')
1652
+ const pending = store.readStream(stream.id, { waitSeconds: 5 })
1653
+ await Bun.sleep(100)
1654
+ const chunk = await store.appendStreamParts(stream.id, [
1655
+ { type: 'text-delta', id: 't', delta: 'wake' },
1656
+ ])
1657
+ const read = await pending
1658
+ expect(read.chunks).toEqual([chunk])
1659
+ })
1660
+ })
1661
+
1662
+ test('bounded wait returns early with the closed status when the stream closes during the wait', async () => {
1663
+ await withStore(factory, undefined, async (store) => {
1664
+ await store.createBuild(sampleBuildInput('st-wake-close'))
1665
+ const stream = await store.createStream({ kind: 'build', build: 'st-wake-close' }, 'w')
1666
+ const pending = store.readStream(stream.id, { waitSeconds: 5 })
1667
+ await Bun.sleep(100)
1668
+ const record = await store.closeStream(stream.id, 'completed')
1669
+ const read = await pending
1670
+ expect(read.chunks).toEqual([])
1671
+ expect(read.status).toBe('closed')
1672
+ expect(read.outcome).toBe('completed')
1673
+ expect(read.artifact).toEqual(record.artifact)
1674
+ })
1675
+ })
1676
+
1677
+ test('a wait above 30 seconds is clamped, not stretched', async () => {
1678
+ await withStore(factory, undefined, async (store) => {
1679
+ await store.createBuild(sampleBuildInput('st-clamp'))
1680
+ const stream = await store.createStream({ kind: 'build', build: 'st-clamp' }, 'c')
1681
+ const pending = store.readStream(stream.id, { waitSeconds: 61 })
1682
+ await Bun.sleep(200)
1683
+ await store.appendStreamParts(stream.id, [{ type: 'text-delta', id: 't', delta: 'x' }])
1684
+ const started = Date.now()
1685
+ const read = await pending
1686
+ expect(read.chunks).toHaveLength(1)
1687
+ // The append satisfied the read — the clamp never stretched it to
1688
+ // 30 or 61 seconds; the wait ended promptly after the append.
1689
+ expect(Date.now() - started).toBeLessThan(5_000)
1690
+ })
1691
+ })
1692
+
1693
+ test('clampWaitSeconds clamps above 30 and below 0', () => {
1694
+ expect(clampWaitSeconds(61)).toBe(MAX_STREAM_WAIT_SECONDS)
1695
+ expect(clampWaitSeconds(30)).toBe(30)
1696
+ expect(clampWaitSeconds(5)).toBe(5)
1697
+ expect(clampWaitSeconds(0)).toBe(0)
1698
+ expect(clampWaitSeconds(-3)).toBe(0)
1699
+ })
1700
+
1701
+ test('reads of a closed stream never wait', async () => {
1702
+ await withStore(factory, undefined, async (store) => {
1703
+ await store.createBuild(sampleBuildInput('st-closed-read'))
1704
+ const stream = await store.createStream({ kind: 'build', build: 'st-closed-read' }, 'c')
1705
+ await store.closeStream(stream.id, 'completed')
1706
+ const started = Date.now()
1707
+ const read = await store.readStream(stream.id, { waitSeconds: 5 })
1708
+ expect(read.status).toBe('closed')
1709
+ expect(Date.now() - started).toBeLessThan(500)
1710
+ })
1711
+ })
1712
+
1713
+ test('appending to a closed stream rejects with StreamClosedError and writes nothing', async () => {
1714
+ await withStore(factory, undefined, async (store) => {
1715
+ await store.createBuild(sampleBuildInput('st-closed-append'))
1716
+ const stream = await store.createStream({ kind: 'build', build: 'st-closed-append' }, 'c')
1717
+ await store.appendStreamParts(stream.id, [{ type: 'text-delta', id: 't', delta: 'x' }])
1718
+ await store.closeStream(stream.id, 'completed')
1719
+ const err = await store
1720
+ .appendStreamParts(stream.id, [{ type: 'text-delta', id: 't', delta: 'y' }])
1721
+ .catch((e: unknown) => e)
1722
+ expect(err).toBeInstanceOf(StreamClosedError)
1723
+ expect((await store.readStream(stream.id)).chunks).toHaveLength(1)
1724
+ })
1725
+ })
1726
+
1727
+ test('close deposits the assembled document atomically: artifact on the owning scope, record gains outcome/closedAt/artifact', async () => {
1728
+ const clock = manualClock(CONTRACT_T0)
1729
+ await withStore(factory, { clock }, async (store) => {
1730
+ await store.createBuild(sampleBuildInput('st-close'))
1731
+ const stream = await store.createStream({ kind: 'build', build: 'st-close' }, 'turn 1')
1732
+ for (const part of representativeStreamParts()) {
1733
+ await store.appendStreamParts(stream.id, [part])
1734
+ }
1735
+ clock.advance(1000)
1736
+ const record = await store.closeStream(stream.id, 'completed')
1737
+ expect(record.status).toBe('closed')
1738
+ expect(record.outcome).toBe('completed')
1739
+ expect(record.closedAt).toBe(atT0(1000))
1740
+ expect(record.artifact?.kind).toBe(`stream:${stream.id}`)
1741
+ expect(record.artifact?.revision).toBe(0)
1742
+
1743
+ // The finalized document is the assembled UIMessage[]...
1744
+ const artifact = await store.getArtifact('st-close', `stream:${stream.id}`)
1745
+ expect(artifact?.meta.kind).toBe(`stream:${stream.id}`)
1746
+ expect(artifact?.meta.revision).toBe(0)
1747
+ expect(artifact?.meta.metadata).toEqual({
1748
+ stream: stream.id,
1749
+ label: 'turn 1',
1750
+ scope: { kind: 'build', build: 'st-close' },
1751
+ outcome: 'completed',
1752
+ chunkCount: 12,
1753
+ droppedPartCount: 1,
1754
+ })
1755
+ expect(JSON.parse(textContent(artifact!))).toEqual(representativeStreamDocument())
1756
+
1757
+ // ...and the read surface carries the closed shape.
1758
+ const read = await store.readStream(stream.id)
1759
+ expect(read.status).toBe('closed')
1760
+ expect(read.outcome).toBe('completed')
1761
+ expect(read.artifact).toEqual(record.artifact)
1762
+ expect(read.chunks).toHaveLength(12)
1763
+ })
1764
+ })
1765
+
1766
+ test('closing an already-closed stream is a no-op returning the record, even with a different outcome', async () => {
1767
+ await withStore(factory, undefined, async (store) => {
1768
+ await store.createBuild(sampleBuildInput('st-idem'))
1769
+ const stream = await store.createStream({ kind: 'build', build: 'st-idem' }, 'i')
1770
+ const closed = await store.closeStream(stream.id, 'completed')
1771
+ const again = await store.closeStream(stream.id, 'aborted')
1772
+ expect(again).toEqual(closed)
1773
+ expect(again.outcome).toBe('completed')
1774
+ expect((await store.getStream(stream.id))?.artifact).toEqual(closed.artifact)
1775
+ })
1776
+ })
1777
+
1778
+ test('close of an unknown stream rejects', async () => {
1779
+ await withStore(factory, undefined, async (store) => {
1780
+ const err = await store.closeStream('st_ghost', 'completed').catch((e: unknown) => e)
1781
+ expect(err).toBeInstanceOf(Error)
1782
+ })
1783
+ })
1784
+
1785
+ test('chunk retention prunes older closed streams at the next create in the same scope; artifacts and open streams survive', async () => {
1786
+ await withStore(factory, undefined, async (store) => {
1787
+ await store.createBuild(sampleBuildInput('st-prune'))
1788
+ const first = await store.createStream({ kind: 'build', build: 'st-prune' }, 'one')
1789
+ await store.appendStreamParts(first.id, [{ type: 'text-delta', id: 't', delta: '1' }])
1790
+ const firstClosed = await store.closeStream(first.id, 'completed')
1791
+
1792
+ await Bun.sleep(5)
1793
+ const second = await store.createStream({ kind: 'build', build: 'st-prune' }, 'two')
1794
+ await store.appendStreamParts(second.id, [{ type: 'text-delta', id: 't', delta: '2' }])
1795
+ await store.closeStream(second.id, 'completed')
1796
+
1797
+ // An open stream and a finalized artifact in the same scope.
1798
+ const open = await store.createStream({ kind: 'build', build: 'st-prune' }, 'open')
1799
+ await store.appendStreamParts(open.id, [{ type: 'text-delta', id: 't', delta: 'o' }])
1800
+
1801
+ // Creating a third stream prunes every closed stream's chunks
1802
+ // except the most recently closed one, in the same transaction.
1803
+ await store.createStream({ kind: 'build', build: 'st-prune' }, 'three')
1804
+
1805
+ const pruned = await store.readStream(first.id)
1806
+ expect(pruned.chunks).toEqual([])
1807
+ expect(pruned.status).toBe('closed')
1808
+ expect(pruned.artifact).toEqual(firstClosed.artifact)
1809
+ // The finalized artifact itself is never touched.
1810
+ expect(await store.getArtifact('st-prune', `stream:${first.id}`)).not.toBeNull()
1811
+
1812
+ const survivor = await store.readStream(second.id)
1813
+ expect(survivor.chunks).toHaveLength(1)
1814
+ expect(await store.getArtifact('st-prune', `stream:${second.id}`)).not.toBeNull()
1815
+
1816
+ const openRead = await store.readStream(open.id)
1817
+ expect(openRead.chunks).toHaveLength(1)
1818
+ expect(openRead.status).toBe('open')
1819
+ })
1820
+ })
1821
+
1822
+ test('build-scoped handles operate on their own streams and reject foreign and repo-scoped ones', async () => {
1823
+ await withStore(factory, undefined, async (store) => {
1824
+ await store.createBuild(sampleBuildInput('st-scope-a'))
1825
+ await store.createBuild(sampleBuildInput('st-scope-b'))
1826
+ await store.ensureRepo('acme/rate-limiter')
1827
+ const scoped = store.scopeBuild('st-scope-a')
1828
+
1829
+ const own = await scoped.createStream({ kind: 'build', build: 'st-scope-a' }, 'mine')
1830
+ expect(own.scope).toEqual({ kind: 'build', build: 'st-scope-a' })
1831
+ await scoped.appendStreamParts(own.id, [{ type: 'text-delta', id: 't', delta: 'x' }])
1832
+ expect((await scoped.readStream(own.id)).chunks).toHaveLength(1)
1833
+ expect(await scoped.getStream(own.id)).toEqual(own)
1834
+ expect(
1835
+ (await scoped.listStreams({ kind: 'build', build: 'st-scope-a' })).map((r) => r.id),
1836
+ ).toEqual([own.id])
1837
+ await scoped.closeStream(own.id, 'completed')
1838
+
1839
+ // A foreign build's stream (created via the unscoped store) rejects
1840
+ // on all six operations.
1841
+ const foreign = await store.createStream(
1842
+ { kind: 'build', build: 'st-scope-b' },
1843
+ 'not mine',
1844
+ )
1845
+ const repoStream = await store.createStream(
1846
+ { kind: 'repo', repo: 'acme/rate-limiter' },
1847
+ 'journal stream',
1848
+ )
1849
+ for (const [operation, attempt] of [
1850
+ [
1851
+ 'createStream',
1852
+ () => scoped.createStream({ kind: 'build', build: 'st-scope-b' }, 'x'),
1853
+ ],
1854
+ [
1855
+ 'createStream',
1856
+ () => scoped.createStream({ kind: 'repo', repo: 'acme/rate-limiter' }, 'x'),
1857
+ ],
1858
+ [
1859
+ 'appendStreamParts',
1860
+ () =>
1861
+ scoped.appendStreamParts(foreign.id, [{ type: 'text-delta', id: 't', delta: 'x' }]),
1862
+ ],
1863
+ [
1864
+ 'appendStreamParts',
1865
+ () =>
1866
+ scoped.appendStreamParts(repoStream.id, [
1867
+ { type: 'text-delta', id: 't', delta: 'x' },
1868
+ ]),
1869
+ ],
1870
+ ['readStream', () => scoped.readStream(foreign.id)],
1871
+ ['readStream', () => scoped.readStream(repoStream.id)],
1872
+ ['closeStream', () => scoped.closeStream(foreign.id, 'completed')],
1873
+ ['closeStream', () => scoped.closeStream(repoStream.id, 'completed')],
1874
+ ['getStream', () => scoped.getStream(foreign.id)],
1875
+ ['getStream', () => scoped.getStream(repoStream.id)],
1876
+ ['listStreams', () => scoped.listStreams({ kind: 'build', build: 'st-scope-b' })],
1877
+ ['listStreams', () => scoped.listStreams({ kind: 'repo', repo: 'acme/rate-limiter' })],
1878
+ ] as const) {
1879
+ const err = await attempt().catch((e: unknown) => e)
1880
+ expect(err, `${operation} must reject`).toBeInstanceOf(Error)
1881
+ expect((err as Error).message).toContain('build-scoped store')
1882
+ }
1883
+ })
1884
+ })
1885
+
1886
+ test('repo-scoped streams run the full lifecycle with the artifact deposited on the repo scope', async () => {
1887
+ const clock = manualClock(CONTRACT_T0)
1888
+ await withStore(factory, { clock }, async (store) => {
1889
+ await store.ensureRepo('acme/rate-limiter')
1890
+ const stream = await store.createStream(
1891
+ { kind: 'repo', repo: 'acme/rate-limiter' },
1892
+ 'harvest run',
1893
+ )
1894
+ await store.appendStreamParts(stream.id, [{ type: 'start', messageId: 'hm' }])
1895
+ await store.appendStreamParts(stream.id, [
1896
+ { type: 'text-start', id: 't' },
1897
+ { type: 'text-delta', id: 't', delta: 'scanning' },
1898
+ { type: 'text-end', id: 't' },
1899
+ ])
1900
+ clock.advance(1000)
1901
+ const record = await store.closeStream(stream.id, 'aborted')
1902
+ expect(record.scope).toEqual({ kind: 'repo', repo: 'acme/rate-limiter' })
1903
+ expect(record.outcome).toBe('aborted')
1904
+ const artifact = await store.getRepoArtifact('acme/rate-limiter', `stream:${stream.id}`)
1905
+ expect(artifact?.meta.metadata).toMatchObject({
1906
+ stream: stream.id,
1907
+ label: 'harvest run',
1908
+ scope: { kind: 'repo', repo: 'acme/rate-limiter' },
1909
+ outcome: 'aborted',
1910
+ })
1911
+ expect(JSON.parse(artifactText(artifact!))).toEqual([
1912
+ {
1913
+ id: 'hm',
1914
+ role: 'assistant',
1915
+ parts: [{ type: 'text', text: 'scanning', state: 'done' }],
1916
+ },
1917
+ ])
1918
+ expect(
1919
+ (await store.listStreams({ kind: 'repo', repo: 'acme/rate-limiter' })).map((r) => r.id),
1920
+ ).toEqual([stream.id])
1921
+ })
1922
+ })
1923
+
1924
+ test('session-scoped streams run the full lifecycle with the artifact deposited on the session scope', async () => {
1925
+ const clock = manualClock(CONTRACT_T0)
1926
+ await withStore(factory, { clock }, async (store) => {
1927
+ const session = await store.createSession({
1928
+ repo: 'acme/rate-limiter',
1929
+ operator: 'operator',
1930
+ title: 'orchestrator',
1931
+ })
1932
+ const stream = await store.createStream(
1933
+ { kind: 'session', session: session.id },
1934
+ 'turn one',
1935
+ )
1936
+ await store.appendStreamParts(stream.id, [{ type: 'start', messageId: 'om' }])
1937
+ await store.appendStreamParts(stream.id, [
1938
+ { type: 'text-start', id: 't' },
1939
+ { type: 'text-delta', id: 't', delta: 'thinking' },
1940
+ { type: 'text-end', id: 't' },
1941
+ ])
1942
+ clock.advance(1000)
1943
+ const record = await store.closeStream(stream.id, 'completed')
1944
+ expect(record.scope).toEqual({ kind: 'session', session: session.id })
1945
+ expect(record.outcome).toBe('completed')
1946
+ const artifact = await store.getSessionArtifact(session.id, `stream:${stream.id}`)
1947
+ expect(artifact?.meta.metadata).toMatchObject({
1948
+ stream: stream.id,
1949
+ label: 'turn one',
1950
+ scope: { kind: 'session', session: session.id },
1951
+ outcome: 'completed',
1952
+ })
1953
+ expect(JSON.parse(artifactText(artifact!))).toEqual([
1954
+ {
1955
+ id: 'om',
1956
+ role: 'assistant',
1957
+ parts: [{ type: 'text', text: 'thinking', state: 'done' }],
1958
+ },
1959
+ ])
1960
+ expect(
1961
+ (await store.listStreams({ kind: 'session', session: session.id })).map((r) => r.id),
1962
+ ).toEqual([stream.id])
1963
+ })
1964
+ })
1965
+
1966
+ describe('operator sessions (SPEC §7.1.1 — a third resource kind)', () => {
1967
+ test('createSession assigns an os_ id, clock timestamps, and appends session.created at seq 1', async () => {
1968
+ const clock = manualClock(CONTRACT_T0)
1969
+ await withStore(factory, { clock }, async (store) => {
1970
+ const record = await store.createSession({
1971
+ repo: 'acme/a',
1972
+ operator: ' operator ',
1973
+ title: 'orchestrator',
1974
+ })
1975
+ expect(record.id).toMatch(/^os_/)
1976
+ expect(record.repo).toBe('acme/a')
1977
+ expect(record.operator).toBe('operator')
1978
+ expect(record.title).toBe('orchestrator')
1979
+ expect(record.createdAt).toBe(CONTRACT_T0)
1980
+ expect(record.updatedAt).toBe(CONTRACT_T0)
1981
+ expect(await store.getSession(record.id)).toEqual(record)
1982
+ const events = await store.getSessionEvents(record.id)
1983
+ expect(events).toHaveLength(1)
1984
+ expect(events[0]).toMatchObject({
1985
+ session: record.id,
1986
+ seq: 1,
1987
+ ts: CONTRACT_T0,
1988
+ actor: { kind: 'human', user: 'operator' },
1989
+ type: 'session.created',
1990
+ payload: { title: 'orchestrator' },
1991
+ })
1992
+ })
1993
+ })
1994
+
1995
+ test('getSession returns null for an unknown session; listSessions isolates by repository and pins the same-timestamp tiebreak', async () => {
1996
+ // The pinned tiebreak (store/types.ts): same-millisecond sessions
1997
+ // order by the store-assigned creation sequence — creation order,
1998
+ // never the random os_ id. The clock is injected and NOT advanced
1999
+ // for the first three creations, so they share one timestamp and the
2000
+ // returned order must be creation order on every adapter.
2001
+ const clock = manualClock(CONTRACT_T0)
2002
+ await withStore(factory, { clock }, async (store) => {
2003
+ expect(await store.getSession('os_never')).toBeNull()
2004
+ const a = await store.createSession({ repo: 'acme/a', operator: 'op' })
2005
+ const a2 = await store.createSession({ repo: 'acme/a', operator: 'op' })
2006
+ const a3 = await store.createSession({ repo: 'acme/a', operator: 'op' })
2007
+ const b = await store.createSession({ repo: 'acme/b', operator: 'op' })
2008
+ expect([a.createdAt, a2.createdAt, a3.createdAt, b.createdAt]).toEqual([
2009
+ CONTRACT_T0,
2010
+ CONTRACT_T0,
2011
+ CONTRACT_T0,
2012
+ CONTRACT_T0,
2013
+ ])
2014
+ expect((await store.listSessions('acme/a')).map((s) => s.id)).toEqual([
2015
+ a.id,
2016
+ a2.id,
2017
+ a3.id,
2018
+ ])
2019
+ expect((await store.listSessions('acme/b')).map((s) => s.id)).toEqual([b.id])
2020
+ expect(await store.listSessions('acme/never')).toEqual([])
2021
+ // Distinct-timestamp ordering is unchanged.
2022
+ clock.advance(1)
2023
+ const a4 = await store.createSession({ repo: 'acme/a', operator: 'op' })
2024
+ clock.advance(1)
2025
+ const a5 = await store.createSession({ repo: 'acme/a', operator: 'op' })
2026
+ expect(a4.createdAt > a2.createdAt).toBe(true)
2027
+ expect(a5.createdAt > a4.createdAt).toBe(true)
2028
+ expect((await store.listSessions('acme/a')).map((s) => s.id)).toEqual([
2029
+ a.id,
2030
+ a2.id,
2031
+ a3.id,
2032
+ a4.id,
2033
+ a5.id,
2034
+ ])
2035
+ })
2036
+ })
2037
+
2038
+ test('a blank operator identity rejects', async () => {
2039
+ await withStore(factory, undefined, async (store) => {
2040
+ const err = await store
2041
+ .createSession({ repo: 'acme/a', operator: ' ' })
2042
+ .catch((e: unknown) => e)
2043
+ expect(err).toBeInstanceOf(Error)
2044
+ })
2045
+ })
2046
+
2047
+ test('per-session sequencing is independent across sessions and updates the record', async () => {
2048
+ const clock = manualClock(CONTRACT_T0)
2049
+ await withStore(factory, { clock }, async (store) => {
2050
+ const a = await store.createSession({ repo: 'acme/a', operator: 'op' })
2051
+ const b = await store.createSession({ repo: 'acme/a', operator: 'op' })
2052
+ clock.advance(1000)
2053
+ const a2 = await store.appendSessionEvent(a.id, messagePostedWrite('first'))
2054
+ clock.advance(1000)
2055
+ const a3 = await store.appendSessionEvent(a.id, messagePostedWrite('second'))
2056
+ const b2 = await store.appendSessionEvent(b.id, messagePostedWrite('other'))
2057
+ expect([a2.seq, a3.seq]).toEqual([2, 3])
2058
+ expect(b2.seq).toBe(2)
2059
+ expect((await store.getSession(a.id))?.updatedAt).toBe(atT0(2000))
2060
+ expect((await store.getSessionEvents(a.id)).map((e) => e.seq)).toEqual([1, 2, 3])
2061
+ expect((await store.getSessionEvents(a.id, 1)).map((e) => e.seq)).toEqual([2, 3])
2062
+ expect(await store.getSessionEvents(a.id, 3)).toEqual([])
2063
+ })
2064
+ })
2065
+
2066
+ test('appendSessionEvent validation rejects unknown types, malformed payloads, and wrong actor kinds, leaving the log untouched', async () => {
2067
+ await withStore(factory, undefined, async (store) => {
2068
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2069
+ const cases = [
2070
+ // Unknown type.
2071
+ {
2072
+ actor: humanActor('op'),
2073
+ type: 'no.such-type',
2074
+ payload: {},
2075
+ },
2076
+ // Malformed payload: message.posted requires nonempty text.
2077
+ { actor: humanActor('op'), type: 'message.posted', payload: { text: '' } },
2078
+ // Wrong actor kind: only humans post messages.
2079
+ {
2080
+ actor: agentActor('orchestrator', 'os_t'),
2081
+ type: 'message.posted',
2082
+ payload: { text: 'hi' },
2083
+ },
2084
+ // Wrong actor kind: only the agent starts turns.
2085
+ {
2086
+ actor: humanActor('op'),
2087
+ type: 'turn.started',
2088
+ payload: {
2089
+ turn: 't1',
2090
+ stream: 'st_t1',
2091
+ trigger: { kind: 'message', messageSeq: 1 },
2092
+ },
2093
+ },
2094
+ ] as const
2095
+ for (const bad of cases) {
2096
+ const err = await store
2097
+ .appendSessionEvent(session.id, bad as unknown as SessionEventWrite)
2098
+ .catch((e: unknown) => e)
2099
+ expect(err).toBeInstanceOf(EventValidationError)
2100
+ }
2101
+ expect((await store.getSessionEvents(session.id)).map((e) => e.seq)).toEqual([1])
2102
+ })
2103
+ })
2104
+
2105
+ test('appendSessionEvent on an unknown session rejects', async () => {
2106
+ await withStore(factory, undefined, async (store) => {
2107
+ const err = await store
2108
+ .appendSessionEvent('os_ghost', messagePostedWrite())
2109
+ .catch((e: unknown) => e)
2110
+ expect(err).toBeInstanceOf(Error)
2111
+ expect((err as Error).message).toContain('unknown session')
2112
+ })
2113
+ })
2114
+
2115
+ test('bounded-wait session event read: early return on append, waits out the bound when empty, clamps above 30', async () => {
2116
+ await withStore(factory, undefined, async (store) => {
2117
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2118
+ // Early return when an event lands during the wait.
2119
+ const pending = store.getSessionEvents(session.id, 1, { waitSeconds: 5 })
2120
+ await Bun.sleep(100)
2121
+ const appended = await store.appendSessionEvent(session.id, messagePostedWrite('wake'))
2122
+ const woke = await pending
2123
+ expect(woke.map((e) => e.seq)).toEqual([appended.seq])
2124
+
2125
+ // Waits out the bound when nothing arrives.
2126
+ const started = Date.now()
2127
+ const empty = await store.getSessionEvents(session.id, 99, { waitSeconds: 1 })
2128
+ expect(empty).toEqual([])
2129
+ expect(Date.now() - started).toBeGreaterThanOrEqual(950)
2130
+ })
2131
+ })
2132
+
2133
+ test('session deposits are atomic: success, invalid-event rollback, validation-before-prune', async () => {
2134
+ await withStore(factory, undefined, async (store) => {
2135
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2136
+ const { event, artifacts } = await store.appendSessionWithArtifacts(
2137
+ session.id,
2138
+ [{ kind: 'turn-context', content: '{"turn":"t1"}' }],
2139
+ () => ({
2140
+ actor: agentActor('orchestrator', 'os_turn'),
2141
+ type: 'turn.started',
2142
+ payload: {
2143
+ turn: 't1',
2144
+ stream: 'st_t1',
2145
+ trigger: { kind: 'message', messageSeq: 1 },
2146
+ },
2147
+ }),
2148
+ )
2149
+ expect(artifacts.map((meta) => meta.revision)).toEqual([0])
2150
+ expect(event.seq).toBe(2)
2151
+ expect(
2152
+ (await store.getSessionArtifact(session.id, 'turn-context'))?.meta.revision,
2153
+ ).toBe(0)
2154
+
2155
+ // An invalid event rolls the whole deposit back.
2156
+ const err = await store
2157
+ .appendSessionWithArtifacts(
2158
+ session.id,
2159
+ [{ kind: 'turn-context', content: 'second' }],
2160
+ () =>
2161
+ ({
2162
+ actor: humanActor('op'),
2163
+ type: 'turn.started',
2164
+ payload: {
2165
+ turn: 't2',
2166
+ stream: 'st_t2',
2167
+ trigger: { kind: 'message', messageSeq: 1 },
2168
+ },
2169
+ }) as unknown as SessionEventWrite,
2170
+ )
2171
+ .catch((e: unknown) => e)
2172
+ expect(err).toBeInstanceOf(EventValidationError)
2173
+ expect((await store.listSessionArtifacts(session.id, 'turn-context')).length).toBe(1)
2174
+ expect((await store.getSessionEvents(session.id)).map((e) => e.seq)).toEqual([1, 2])
2175
+ })
2176
+ })
2177
+
2178
+ test('session artifacts version per kind and read latest/pinned', async () => {
2179
+ await withStore(factory, undefined, async (store) => {
2180
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2181
+ const a0 = await store.putSessionArtifact(session.id, {
2182
+ kind: 'context',
2183
+ content: 'v0',
2184
+ })
2185
+ const a1 = await store.putSessionArtifact(session.id, {
2186
+ kind: 'context',
2187
+ content: 'v1',
2188
+ })
2189
+ expect([a0.revision, a1.revision]).toEqual([0, 1])
2190
+ expect((await store.getSessionArtifact(session.id, 'context'))?.meta.revision).toBe(1)
2191
+ expect((await store.getSessionArtifact(session.id, 'context', 0))?.meta.revision).toBe(
2192
+ 0,
2193
+ )
2194
+ expect(await store.getSessionArtifact(session.id, 'never')).toBeNull()
2195
+ expect(
2196
+ (await store.listSessionArtifacts(session.id)).map((m) => [m.kind, m.revision]),
2197
+ ).toEqual([
2198
+ ['context', 0],
2199
+ ['context', 1],
2200
+ ])
2201
+ })
2202
+ })
2203
+
2204
+ test('session-scoped handles touch only their own session and reject everything else', async () => {
2205
+ await withStore(factory, undefined, async (store) => {
2206
+ const own = await store.createSession({ repo: 'acme/a', operator: 'op' })
2207
+ const foreign = await store.createSession({ repo: 'acme/a', operator: 'op' })
2208
+ await store.ensureRepo('acme/a')
2209
+ await store.createBuild(sampleBuildInput('scope-build'))
2210
+ const scoped = store.scopeSession(own.id)
2211
+ expect(scoped.sessionScope).toBe(own.id)
2212
+
2213
+ // Own operations work.
2214
+ expect(await scoped.getSession(own.id)).not.toBeNull()
2215
+ await scoped.appendSessionEvent(own.id, messagePostedWrite('mine'))
2216
+ await scoped.putSessionArtifact(own.id, { kind: 'notes', content: 'owned' })
2217
+ expect((await scoped.getSessionArtifact(own.id, 'notes'))?.meta.revision).toBe(0)
2218
+ const ownStream = await scoped.createStream(
2219
+ { kind: 'session', session: own.id },
2220
+ 'turn',
2221
+ )
2222
+ await scoped.appendStreamParts(ownStream.id, [
2223
+ { type: 'text-delta', id: 't', delta: 'x' },
2224
+ ])
2225
+ expect((await scoped.readStream(ownStream.id)).chunks).toHaveLength(1)
2226
+ expect(
2227
+ (await scoped.listStreams({ kind: 'session', session: own.id })).map((r) => r.id),
2228
+ ).toEqual([ownStream.id])
2229
+ expect(scoped.scopeSession(own.id)).toBe(scoped)
2230
+
2231
+ // Foreign session, create/list, and every build/repository operation reject.
2232
+ const attempts = [
2233
+ () => scoped.getSession(foreign.id),
2234
+ () => scoped.appendSessionEvent(foreign.id, messagePostedWrite()),
2235
+ () => scoped.listSessions('acme/a'),
2236
+ () => scoped.createSession({ repo: 'acme/a', operator: 'op' }),
2237
+ () => scoped.createStream({ kind: 'session', session: foreign.id }, 'x'),
2238
+ () => scoped.listStreams({ kind: 'session', session: foreign.id }),
2239
+ () => scoped.getBuild('scope-build'),
2240
+ () => scoped.append('scope-build', sampleEventWrite()),
2241
+ () => scoped.listBuilds(),
2242
+ () => scoped.createBuild(sampleBuildInput('scope-build-2')),
2243
+ () => scoped.ensureRepo('acme/a'),
2244
+ () => scoped.appendRepo('acme/a', harvestStartedWrite()),
2245
+ () => scoped.getRepoEvents('acme/a'),
2246
+ () => scoped.createStream({ kind: 'build', build: 'scope-build' }, 'x'),
2247
+ () => scoped.createStream({ kind: 'repo', repo: 'acme/a' }, 'x'),
2248
+ () => scoped.close(),
2249
+ ] as const
2250
+ for (const attempt of attempts) {
2251
+ const err = await attempt().catch((e: unknown) => e)
2252
+ expect(err, `${attempt.toString()} must reject`).toBeInstanceOf(Error)
2253
+ expect((err as Error).message).toContain('session-scoped store')
2254
+ }
2255
+ })
2256
+ })
2257
+
2258
+ test('build-scoped handles reject session operations and session-scoped streams', async () => {
2259
+ await withStore(factory, undefined, async (store) => {
2260
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2261
+ await store.createBuild(sampleBuildInput('bscope'))
2262
+ const scoped = store.scopeBuild('bscope')
2263
+ const sessionStream = await store.createStream(
2264
+ { kind: 'session', session: session.id },
2265
+ 'turn',
2266
+ )
2267
+ const attempts = [
2268
+ () => scoped.getSession(session.id),
2269
+ () => scoped.appendSessionEvent(session.id, messagePostedWrite()),
2270
+ () => scoped.listSessions('acme/a'),
2271
+ () => scoped.createSession({ repo: 'acme/a', operator: 'op' }),
2272
+ () => scoped.putSessionArtifact(session.id, { kind: 'x', content: 'x' }),
2273
+ () => scoped.listSessionArtifacts(session.id),
2274
+ () => scoped.createStream({ kind: 'session', session: session.id }, 'x'),
2275
+ () =>
2276
+ scoped.appendStreamParts(sessionStream.id, [
2277
+ { type: 'text-delta', id: 't', delta: 'x' },
2278
+ ]),
2279
+ () => scoped.readStream(sessionStream.id),
2280
+ () => scoped.listStreams({ kind: 'session', session: session.id }),
2281
+ ] as const
2282
+ for (const attempt of attempts) {
2283
+ const err = await attempt().catch((e: unknown) => e)
2284
+ expect(err, `${attempt.toString()} must reject`).toBeInstanceOf(Error)
2285
+ expect((err as Error).message).toContain('build-scoped store')
2286
+ }
2287
+ })
2288
+ })
2289
+
2290
+ test('session streams never list under build or repo scopes and vice versa', async () => {
2291
+ await withStore(factory, undefined, async (store) => {
2292
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2293
+ await store.createBuild(sampleBuildInput('st-mix'))
2294
+ await store.ensureRepo('acme/a')
2295
+ const sessionStream = await store.createStream(
2296
+ { kind: 'session', session: session.id },
2297
+ 'turn',
2298
+ )
2299
+ const buildStream = await store.createStream(
2300
+ { kind: 'build', build: 'st-mix' },
2301
+ 'phase',
2302
+ )
2303
+ expect(
2304
+ (await store.listStreams({ kind: 'session', session: session.id })).map((r) => r.id),
2305
+ ).toEqual([sessionStream.id])
2306
+ expect(
2307
+ (await store.listStreams({ kind: 'build', build: 'st-mix' })).map((r) => r.id),
2308
+ ).toEqual([buildStream.id])
2309
+ expect(await store.listStreams({ kind: 'repo', repo: 'acme/a' })).toEqual([])
2310
+ })
2311
+ })
2312
+ })
2313
+
2314
+ describe('via attribution (delegated writes, SPEC §15.1)', () => {
2315
+ // The via contract is per-transport, and the harness's `viaAuthority`
2316
+ // posture selects the branch. Absent (memory, SQLite, PostgreSQL, and
2317
+ // the open no-secret remote server): the backing catalog is the only
2318
+ // via gate, so every rejection is a 422 `EventValidationError`. Present
2319
+ // (a transport with token authority — the hosted HTTP store): the
2320
+ // server's `enforceVia` runs after token verification and before
2321
+ // catalog validation, so a human write claiming a via the token does
2322
+ // not carry — a valid shape or a malformed one — rejects with 403
2323
+ // `auth` (`AuthError` on the remote client) before the catalog ever
2324
+ // sees it. The 403-vs-422 split is the designed signal: "not your
2325
+ // delegate" vs "malformed event" (SPEC §15.1).
2326
+ const viaSession: Via = { kind: 'session', id: 'os_delegate' }
2327
+ const viaMcp: Via = { kind: 'mcp', client: 'claude-code' }
2328
+ const NO_VIA_MESSAGE = 'token carries no via; it may not write delegated events'
2329
+
2330
+ test('build and repository writes accept human actors carrying each via kind and round-trip it', async () => {
2331
+ await withStore(factory, undefined, async (store, viaAuthority) => {
2332
+ await store.createBuild(sampleBuildInput('via-build'))
2333
+ await store.ensureRepo('acme/via')
2334
+ if (viaAuthority !== undefined) {
2335
+ if (viaAuthority.via === undefined) {
2336
+ // Via-less token posture (the hosted HTTP harness): any human
2337
+ // write claiming a via — this valid shape or a malformed one —
2338
+ // is an authority failure before catalog validation.
2339
+ for (const via of [viaSession, viaMcp]) {
2340
+ const err = await store
2341
+ .append('via-build', {
2342
+ actor: humanActor('operator', via),
2343
+ type: 'build.created',
2344
+ payload: {
2345
+ ticket: sampleBuildInput('via-build').ticket!,
2346
+ repo: 'acme/rate-limiter',
2347
+ baseBranch: 'main',
2348
+ },
2349
+ })
2350
+ .catch((e: unknown) => e)
2351
+ expect(err).toBeInstanceOf(AuthError)
2352
+ expect((err as Error).message).toBe(NO_VIA_MESSAGE)
2353
+ const repoErr = await store
2354
+ .appendRepo('acme/via', {
2355
+ actor: humanActor('operator', via),
2356
+ type: 'dispatcher.intake-set',
2357
+ payload: { enabled: false },
2358
+ })
2359
+ .catch((e: unknown) => e)
2360
+ expect(repoErr).toBeInstanceOf(AuthError)
2361
+ expect((repoErr as Error).message).toBe(NO_VIA_MESSAGE)
2362
+ }
2363
+ expect(await store.getEvents('via-build')).toEqual([])
2364
+ expect(await store.getRepoEvents('acme/via')).toEqual([])
2365
+ // Non-delegated human writes still round-trip faithfully over
2366
+ // the same transport.
2367
+ const plain = await store.append('via-build', {
2368
+ actor: humanActor('operator'),
2369
+ type: 'build.pause-requested',
2370
+ payload: {},
2371
+ })
2372
+ expect(plain.actor).toEqual({ kind: 'human', user: 'operator' })
2373
+ const repoPlain = await store.appendRepo('acme/via', {
2374
+ actor: humanActor('operator'),
2375
+ type: 'dispatcher.intake-set',
2376
+ payload: { enabled: false },
2377
+ })
2378
+ expect(repoPlain.actor).toEqual({ kind: 'human', user: 'operator' })
2379
+ return
2380
+ }
2381
+ // Token carrying a via (kept for future harnesses): the token is
2382
+ // authoritative — it stamps its via onto plain human writes and
2383
+ // accepts only its own claim.
2384
+ const tokenVia = viaAuthority.via
2385
+ const other = tokenVia.kind === 'session' ? viaMcp : viaSession
2386
+ const envelope = await store.append('via-build', {
2387
+ actor: humanActor('operator', tokenVia),
2388
+ type: 'build.created',
2389
+ payload: {
2390
+ ticket: sampleBuildInput('via-build').ticket!,
2391
+ repo: 'acme/rate-limiter',
2392
+ baseBranch: 'main',
2393
+ },
2394
+ })
2395
+ expect(envelope.actor).toEqual({ kind: 'human', user: 'operator', via: tokenVia })
2396
+ const stamped = await store.append('via-build', {
2397
+ actor: humanActor('operator'),
2398
+ type: 'build.pause-requested',
2399
+ payload: {},
2400
+ })
2401
+ expect(stamped.actor).toEqual({ kind: 'human', user: 'operator', via: tokenVia })
2402
+ const mismatch = await store
2403
+ .append('via-build', {
2404
+ actor: humanActor('operator', other),
2405
+ type: 'build.resume-requested',
2406
+ payload: {},
2407
+ })
2408
+ .catch((e: unknown) => e)
2409
+ expect(mismatch).toBeInstanceOf(AuthError)
2410
+ expect((mismatch as Error).message).toBe(
2411
+ `token carries via ${JSON.stringify(tokenVia)}; it may not write events claiming via ${JSON.stringify(other)}`,
2412
+ )
2413
+ return
2414
+ }
2415
+ // No token authority: the catalog is the only gate (today's local
2416
+ // behavior, byte-for-byte).
2417
+ for (const via of [viaSession, viaMcp]) {
2418
+ const envelope = await store.append('via-build', {
2419
+ actor: humanActor('operator', via),
2420
+ type: 'build.created',
2421
+ payload: {
2422
+ ticket: sampleBuildInput('via-build').ticket!,
2423
+ repo: 'acme/rate-limiter',
2424
+ baseBranch: 'main',
2425
+ },
2426
+ })
2427
+ expect(envelope.actor).toEqual({ kind: 'human', user: 'operator', via })
2428
+ await store.appendRepo('acme/via', {
2429
+ actor: humanActor('operator', via),
2430
+ type: 'dispatcher.intake-set',
2431
+ payload: { enabled: false },
2432
+ })
2433
+ }
2434
+ const events = await store.getEvents('via-build')
2435
+ expect(events.map((e) => (e.actor as { via?: Via }).via)).toEqual([viaSession, viaMcp])
2436
+ const repoEvents = await store.getRepoEvents('acme/via')
2437
+ expect(repoEvents.map((e) => (e.actor as { via?: Via }).via)).toEqual([
2438
+ viaSession,
2439
+ viaMcp,
2440
+ ])
2441
+ // Events without via replay unchanged.
2442
+ const plain = await store.append('via-build', {
2443
+ actor: humanActor('operator'),
2444
+ type: 'build.pause-requested',
2445
+ payload: {},
2446
+ })
2447
+ expect(plain.actor).toEqual({ kind: 'human', user: 'operator' })
2448
+ })
2449
+ })
2450
+
2451
+ test('via on any non-human actor rejects with the explicit rule message', async () => {
2452
+ // This is the validation side of the 403/422 split: `enforceVia`
2453
+ // passes non-human actors through, so even token-authority postures
2454
+ // surface the backing catalog's EventValidationError (422 over the
2455
+ // wire). Holds on every transport.
2456
+ await withStore(factory, undefined, async (store) => {
2457
+ await store.createBuild(sampleBuildInput('via-nonhuman'))
2458
+ await store.ensureRepo('acme/via')
2459
+ for (const actor of [
2460
+ { kind: 'kernel', via: viaSession },
2461
+ { kind: 'agent', role: 'implement', session: 's_1', via: viaSession },
2462
+ { kind: 'dispatcher', via: viaMcp },
2463
+ { kind: 'ingester', source: 'webhook', via: viaMcp },
2464
+ ]) {
2465
+ const err = await store
2466
+ .append('via-nonhuman', {
2467
+ actor,
2468
+ type: 'observation.recorded',
2469
+ payload: { id: 'o', kind: 'followup', summary: 'x' },
2470
+ } as unknown as EventWrite)
2471
+ .catch((e: unknown) => e)
2472
+ expect(err).toBeInstanceOf(EventValidationError)
2473
+ expect((err as Error).message).toContain('only human actors may carry via')
2474
+ const repoErr = await store
2475
+ .appendRepo('acme/via', {
2476
+ actor,
2477
+ type: 'harvest.started',
2478
+ payload: harvestStartedWrite().payload,
2479
+ } as unknown as RepositoryEventWrite)
2480
+ .catch((e: unknown) => e)
2481
+ expect(repoErr).toBeInstanceOf(EventValidationError)
2482
+ expect((repoErr as Error).message).toContain('only human actors may carry via')
2483
+ }
2484
+ expect(await store.getEvents('via-nonhuman')).toEqual([])
2485
+ expect(await store.getRepoEvents('acme/via')).toEqual([])
2486
+ })
2487
+ })
2488
+
2489
+ test('malformed via shapes reject', async () => {
2490
+ await withStore(factory, undefined, async (store, viaAuthority) => {
2491
+ await store.createBuild(sampleBuildInput('via-malformed'))
2492
+ for (const via of [
2493
+ { kind: 'session' },
2494
+ { kind: 'session', id: '' },
2495
+ { kind: 'mcp' },
2496
+ { kind: 'mcp', client: '' },
2497
+ { kind: 'slack', channel: 'x' },
2498
+ 'os_delegate',
2499
+ ]) {
2500
+ const err = await store
2501
+ .append('via-malformed', {
2502
+ actor: { kind: 'human', user: 'operator', via },
2503
+ type: 'build.pause-requested',
2504
+ payload: {},
2505
+ } as unknown as EventWrite)
2506
+ .catch((e: unknown) => e)
2507
+ // All six fixtures ride human actors, so under a token posture
2508
+ // `enforceVia` rejects each before the catalog ever sees the
2509
+ // shape — a malformed via under a via-less token is the same 403
2510
+ // authority failure as a valid one (SPEC §15.1).
2511
+ if (viaAuthority !== undefined) {
2512
+ expect(err, `via ${JSON.stringify(via)} must reject`).toBeInstanceOf(AuthError)
2513
+ expect((err as Error).message).toBe(
2514
+ viaAuthority.via === undefined
2515
+ ? NO_VIA_MESSAGE
2516
+ : `token carries via ${JSON.stringify(viaAuthority.via)}; it may not write events claiming via ${JSON.stringify(via)}`,
2517
+ )
2518
+ } else {
2519
+ expect(err, `via ${JSON.stringify(via)} must reject`).toBeInstanceOf(
2520
+ EventValidationError,
2521
+ )
2522
+ }
2523
+ }
2524
+ expect(await store.getEvents('via-malformed')).toEqual([])
2525
+ })
2526
+ })
2527
+ })
2528
+ })
2529
+ })
2530
+ }
2531
+
2532
+ // ── The BlobStore contract ───────────────────────────────────────────────────
2533
+
2534
+ export function describeBlobStoreContract(name: string, factory: BlobStoreFactory): void {
2535
+ describe(`BlobStore contract: ${name}`, () => {
2536
+ async function withBlobs(run: (blobs: BlobStore) => Promise<void>): Promise<void> {
2537
+ const { blobs, cleanup } = await factory()
2538
+ try {
2539
+ await run(blobs)
2540
+ } finally {
2541
+ await cleanup?.()
2542
+ }
2543
+ }
2544
+
2545
+ test('put then get round-trips bytes', async () => {
2546
+ await withBlobs(async (blobs) => {
2547
+ const bytes = toBytes('blob content')
2548
+ const hash = contentHash(bytes)
2549
+ await blobs.put(hash, bytes)
2550
+ expect(await blobs.get(hash)).toEqual(bytes)
2551
+ })
2552
+ })
2553
+
2554
+ test('get returns null when absent', async () => {
2555
+ await withBlobs(async (blobs) => {
2556
+ expect(await blobs.get(contentHash(toBytes('never stored')))).toBeNull()
2557
+ })
2558
+ })
2559
+
2560
+ test('put is idempotent for the same hash (§7.1: content-addressed)', async () => {
2561
+ await withBlobs(async (blobs) => {
2562
+ const bytes = toBytes('same content')
2563
+ const hash = contentHash(bytes)
2564
+ await blobs.put(hash, bytes)
2565
+ await blobs.put(hash, bytes)
2566
+ expect(await blobs.get(hash)).toEqual(bytes)
2567
+ })
2568
+ })
2569
+
2570
+ test('distinct hashes are independent', async () => {
2571
+ await withBlobs(async (blobs) => {
2572
+ const a = toBytes('content a')
2573
+ const b = toBytes('content b')
2574
+ await blobs.put(contentHash(a), a)
2575
+ await blobs.put(contentHash(b), b)
2576
+ expect(await blobs.get(contentHash(a))).toEqual(a)
2577
+ expect(await blobs.get(contentHash(b))).toEqual(b)
2578
+ })
2579
+ })
2580
+ })
2581
+ }