@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,306 @@
1
+ /**
2
+ * The session-stream batching writer (SPEC §9): the store-side sink the
3
+ * build-runner hands to a runtime registration's `openSessionStream`
4
+ * capability. Buffers appended protocol parts and flushes them to the
5
+ * store on a short cadence — well inside the one-second latency bound —
6
+ * instead of one request per delta.
7
+ *
8
+ * Failure containment is the contract: `append` NEVER throws, an append
9
+ * error retries on the next tick, and after three consecutive failed
10
+ * flushes the writer stops streaming the session, best-effort closes the
11
+ * stream as `aborted` (first close wins), and reports once through the
12
+ * diagnostics hook. A failing stream path can therefore never fail a turn,
13
+ * a deposit, or a phase. `close` is flush-then-close, idempotent, first
14
+ * outcome wins.
15
+ *
16
+ * Per-part payloads above the documented `STREAM_PART_MAX_BYTES` are
17
+ * truncated on a UTF-8 boundary and followed by a `data-ab-truncation`
18
+ * part naming the omitted byte count, so no single delta can exceed the
19
+ * store's batch ceiling.
20
+ *
21
+ * Flushes are single-flight: at most one drain runs at a time, so parts are
22
+ * appended to the store strictly in buffer order (per-stream sequencing can
23
+ * never reorder), retries re-queue at the front under the same discipline,
24
+ * and `close` waits out any in-flight drain before closing the stream.
25
+ */
26
+ import type { SessionStreamSink } from '../../ports/types'
27
+ import { STREAM_PART_MAX_BYTES, truncationPart } from '../../ports/runner/stream-parts'
28
+ import type { JsonRecord } from '../../ports/runner/json-record'
29
+ import {
30
+ serializedBatchSize,
31
+ STREAM_BATCH_MAX_BYTES,
32
+ type StreamOutcome,
33
+ type StreamPart,
34
+ type StreamRecord,
35
+ type StreamScope,
36
+ } from './types'
37
+
38
+ export interface SessionStreamSinkOptions {
39
+ /** Any BuildStore handle scoped so `createStream(scope)` succeeds — the
40
+ * build-runner passes its build-scoped handle. */
41
+ store: {
42
+ createStream(scope: StreamScope, label: string): Promise<StreamRecord>
43
+ appendStreamParts(streamId: string, parts: StreamPart[]): Promise<unknown>
44
+ closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord>
45
+ }
46
+ scope: StreamScope
47
+ /** Called at most once per sink, when streaming gives up. */
48
+ onDiagnostic?: (message: string) => void
49
+ /** Flush cadence in milliseconds. Default 250. */
50
+ flushMs?: number
51
+ /** Injectable scheduler for tests. Default: an unref'ed interval. */
52
+ schedule?: (flush: () => void, ms: number) => () => void
53
+ }
54
+
55
+ /** Consecutive failed flushes before the writer stops streaming a session. */
56
+ const MAX_FLUSH_FAILURES = 3
57
+
58
+ /**
59
+ * Cut `text` to at most `maxBytes` UTF-8 bytes without splitting a
60
+ * multi-byte sequence. Returns the prefix and the omitted byte count.
61
+ */
62
+ function truncateUtf8(text: string, maxBytes: number): { text: string; omitted: number } {
63
+ const bytes = new TextEncoder().encode(text)
64
+ if (bytes.byteLength <= maxBytes) return { text, omitted: 0 }
65
+ let cut = maxBytes
66
+ while (cut > 0 && (bytes[cut]! & 0xc0) === 0x80) cut -= 1
67
+ return {
68
+ text: new TextDecoder().decode(bytes.slice(0, cut)),
69
+ omitted: bytes.byteLength - cut,
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Bound one part's payload against `STREAM_PART_MAX_BYTES`. Text and
75
+ * reasoning deltas truncate their `delta`; tool inputs/outputs truncate
76
+ * their JSON serialization, keeping the payload's JSON kind (objects stay
77
+ * objects, strings stay strings); the prompt part truncates `data.text`.
78
+ * Anything else passes through untouched. Returns the (possibly replaced)
79
+ * part plus a `data-ab-truncation` part when bytes were omitted.
80
+ */
81
+ function boundPart(part: StreamPart, maxBytes: number): StreamPart[] {
82
+ const record = part as JsonRecord
83
+ switch (part.type) {
84
+ case 'text-delta':
85
+ case 'reasoning-delta': {
86
+ const delta = record.delta
87
+ if (typeof delta !== 'string') return [part]
88
+ const { text, omitted } = truncateUtf8(delta, maxBytes)
89
+ return omitted === 0
90
+ ? [part]
91
+ : [{ ...record, type: part.type, delta: text }, truncationPart(omitted)]
92
+ }
93
+ case 'tool-input-available':
94
+ case 'tool-output-available': {
95
+ const key = part.type === 'tool-input-available' ? 'input' : 'output'
96
+ const value = record[key]
97
+ if (value === undefined) return [part]
98
+ // Strings truncate as strings; objects stay objects (the shape the
99
+ // part type carries) with the truncated serialization inside.
100
+ if (typeof value === 'string') {
101
+ const { text, omitted } = truncateUtf8(value, maxBytes)
102
+ return omitted === 0
103
+ ? [part]
104
+ : [{ ...record, type: part.type, [key]: text }, truncationPart(omitted)]
105
+ }
106
+ const serialized = JSON.stringify(value)
107
+ if (typeof serialized !== 'string') return [part]
108
+ const { text, omitted } = truncateUtf8(serialized, maxBytes)
109
+ return omitted === 0
110
+ ? [part]
111
+ : [{ ...record, type: part.type, [key]: { truncated: text } }, truncationPart(omitted)]
112
+ }
113
+ default: {
114
+ // data-ab-prompt carries the turn's prompt in data.text; other data
115
+ // parts are small bracket metadata and pass through.
116
+ if (part.type !== 'data-ab-prompt') return [part]
117
+ const data = record.data
118
+ if (typeof data !== 'object' || data === null) return [part]
119
+ const text = (data as JsonRecord).text
120
+ if (typeof text !== 'string') return [part]
121
+ const { text: bounded, omitted } = truncateUtf8(text, maxBytes)
122
+ if (omitted === 0) return [part]
123
+ return [
124
+ { ...record, type: part.type, data: { ...(data as JsonRecord), text: bounded } },
125
+ truncationPart(omitted),
126
+ ]
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Create the bracket-scoped sink. `open` creates the store stream and
133
+ * returns its id; appends buffer until then and flush on the cadence.
134
+ */
135
+ export function createSessionStreamSink(options: SessionStreamSinkOptions): SessionStreamSink {
136
+ const { store, scope } = options
137
+ const flushMs = options.flushMs ?? 250
138
+ const maxPartBytes = STREAM_PART_MAX_BYTES
139
+
140
+ let streamId: string | undefined
141
+ let openError: unknown
142
+ const buffer: StreamPart[] = []
143
+ let flushFailures = 0
144
+ let defunct = false
145
+ let diagnosticSent = false
146
+ let closed = false
147
+ let firstOutcome: StreamOutcome | undefined
148
+ let cancelSchedule: (() => void) | undefined
149
+ /** Single-flight drain discipline: at most one `doFlush` runs at a time;
150
+ * everything else joins the in-flight promise, so the shared buffer is
151
+ * never mutated by two concurrent flushes and per-stream part order is
152
+ * the store-arrival order. */
153
+ let inFlight: Promise<void> | undefined
154
+
155
+ const schedule =
156
+ options.schedule ??
157
+ ((flush, ms) => {
158
+ const timer = setInterval(flush, ms)
159
+ timer.unref?.()
160
+ return () => clearInterval(timer)
161
+ })
162
+
163
+ function onDiagnosticOnce(message: string): void {
164
+ if (diagnosticSent) return
165
+ diagnosticSent = true
166
+ options.onDiagnostic?.(message)
167
+ }
168
+
169
+ function ensureTimer(): void {
170
+ if (cancelSchedule !== undefined || defunct || closed) return
171
+ cancelSchedule = schedule(() => void requestFlush(), flushMs)
172
+ }
173
+
174
+ /** One drain of the buffer. Never overlapping: it only starts when no
175
+ * drain is in flight (`requestFlush` gates that), so the shared buffer is
176
+ * never mutated by two concurrent flushes. */
177
+ async function doFlush(): Promise<void> {
178
+ if (streamId === undefined || defunct || closed) return
179
+ // Split the buffer into store-sized batches (parts were already
180
+ // per-part bounded at append time, so every batch eventually lands).
181
+ // Parts appended mid-drain are picked up by the loop's re-check, so a
182
+ // drain ends only when the buffer is empty or the writer gave up.
183
+ while (streamId !== undefined && !defunct && !closed && buffer.length > 0) {
184
+ const batch: StreamPart[] = [buffer[0]!]
185
+ buffer.shift()
186
+ while (
187
+ buffer.length > 0 &&
188
+ serializedBatchSize([...batch, buffer[0]!]) <= STREAM_BATCH_MAX_BYTES
189
+ ) {
190
+ batch.push(buffer.shift()!)
191
+ }
192
+ try {
193
+ await store.appendStreamParts(streamId, batch)
194
+ flushFailures = 0
195
+ } catch (error) {
196
+ // Put the parts back in order and retry on the next tick.
197
+ buffer.unshift(...batch)
198
+ flushFailures += 1
199
+ if (flushFailures >= MAX_FLUSH_FAILURES) {
200
+ defunct = true
201
+ cancelSchedule?.()
202
+ cancelSchedule = undefined
203
+ onDiagnosticOnce(
204
+ `session stream "${streamId}" stopped after ${MAX_FLUSH_FAILURES} failed appends: ` +
205
+ `${error instanceof Error ? error.message : String(error)}`,
206
+ )
207
+ // Reentrant close: this drain is the current call stack, so
208
+ // `closeSink` skips its own drain (`defunct` guards the loop).
209
+ await closeSink('aborted')
210
+ }
211
+ return
212
+ }
213
+ }
214
+ }
215
+
216
+ /** The one way to start (or join) a drain. Never overlaps a running one. */
217
+ function requestFlush(): Promise<void> {
218
+ if (inFlight !== undefined) return inFlight
219
+ const run = doFlush()
220
+ const tracked = run.finally(() => {
221
+ if (inFlight === tracked) inFlight = undefined
222
+ })
223
+ inFlight = tracked
224
+ return tracked
225
+ }
226
+
227
+ /** First-outcome-wins, flush-then-close, best-effort. An undeliverable
228
+ * buffer at close time means the stream's content is incomplete: it closes
229
+ * `aborted` with one diagnostic, whatever outcome was requested. */
230
+ async function closeSink(outcome: StreamOutcome): Promise<void> {
231
+ if (closed || firstOutcome !== undefined) return
232
+ firstOutcome = outcome
233
+ cancelSchedule?.()
234
+ cancelSchedule = undefined
235
+ if (streamId !== undefined) {
236
+ try {
237
+ // Flush-then-close under the single-flight discipline: wait out any
238
+ // in-flight drain, then keep draining until the buffer is empty —
239
+ // or the writer has given up (defunct, or a failed batch awaiting
240
+ // its next-tick retry, which closes `aborted` below).
241
+ while (
242
+ !defunct &&
243
+ !closed &&
244
+ flushFailures === 0 &&
245
+ (inFlight !== undefined || buffer.length > 0)
246
+ ) {
247
+ if (inFlight !== undefined) {
248
+ await inFlight
249
+ } else {
250
+ await requestFlush()
251
+ }
252
+ }
253
+ } catch {
254
+ // flush is defensive; appends already handle their own failures.
255
+ }
256
+ let effective = outcome
257
+ if (flushFailures > 0) {
258
+ effective = 'aborted'
259
+ onDiagnosticOnce(
260
+ `session stream "${streamId}" closed with ${buffer.length} undelivered parts ` +
261
+ `after ${flushFailures} failed appends`,
262
+ )
263
+ }
264
+ try {
265
+ await store.closeStream(streamId, effective)
266
+ } catch (error) {
267
+ onDiagnosticOnce(
268
+ `session stream "${streamId}" failed to close: ` +
269
+ `${error instanceof Error ? error.message : String(error)}`,
270
+ )
271
+ }
272
+ }
273
+ closed = true
274
+ }
275
+
276
+ return {
277
+ async open(label) {
278
+ try {
279
+ const record = await store.createStream(scope, label)
280
+ streamId = record.id
281
+ ensureTimer()
282
+ // Parts appended while opening flush immediately after the id lands.
283
+ await requestFlush()
284
+ return record.id
285
+ } catch (error) {
286
+ openError = error
287
+ throw error
288
+ }
289
+ },
290
+
291
+ append(parts) {
292
+ // Never throws: after defunct/close or before a successful open,
293
+ // parts are dropped or buffered without surfacing an error.
294
+ if (defunct || closed) return
295
+ if (openError !== undefined && streamId === undefined) return
296
+ for (const part of parts) {
297
+ buffer.push(...boundPart(part, maxPartBytes))
298
+ }
299
+ if (streamId !== undefined) ensureTimer()
300
+ },
301
+
302
+ async close(outcome) {
303
+ await closeSink(outcome)
304
+ },
305
+ }
306
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The stream primitive (SPEC §7.6): the BuildStore's third kind of durable
3
+ * content, alongside the append-only event log of typed facts and the
4
+ * content-addressed artifacts of bulk bytes. A stream is an append-only,
5
+ * per-stream sequenced log of chunks — a chunk being a batch of protocol
6
+ * parts — with an open-then-closed lifecycle that finalizes into an artifact.
7
+ *
8
+ * The chunk vocabulary is the Vercel AI SDK UI Message Stream protocol,
9
+ * version 1 of the current major (AI SDK 7). Storing the protocol's parts
10
+ * rather than a private shape means a read route can later re-emit them as
11
+ * Server-Sent Events that the AI SDK's own client hooks consume unchanged,
12
+ * and a closed stream finalizes into the protocol's `UIMessage[]` document.
13
+ *
14
+ * Streams are presentation, never routing: no kernel, engine, reducer, or
15
+ * dispatcher decision reads stream content; outcomes travel only the typed
16
+ * CLI. This module — like everything under `store/` — encodes nothing about
17
+ * this repository's specifics.
18
+ */
19
+
20
+ /** The literal format identifier carried on every stream record. */
21
+ export const STREAM_FORMAT = 'ai-ui-message-stream/v1'
22
+
23
+ /** The serialized-batch ceiling: the same byte bound artifacts enforce. */
24
+ export const STREAM_BATCH_MAX_BYTES = 1_048_576
25
+
26
+ /** Upper bound for a read's wait, in whole seconds. Larger requests clamp. */
27
+ export const MAX_STREAM_WAIT_SECONDS = 30
28
+
29
+ /** One protocol part: a JSON object with a nonempty-string `type`. The store
30
+ * performs no further protocol validation on append — assembly at close does
31
+ * that, following the protocol for its defined part types and dropping and
32
+ * counting parts of undefined types. */
33
+ export type StreamPart = { type: string } & Record<string, unknown>
34
+
35
+ /** A store-assigned, per-stream sequenced batch of parts. */
36
+ export interface StreamChunk {
37
+ stream: string
38
+ /** Per-stream sequence assigned by the store, starting at 1. Producers
39
+ * cannot fake ordering. */
40
+ seq: number
41
+ ts: string
42
+ parts: StreamPart[]
43
+ }
44
+
45
+ /** Where a stream lives. The vocabulary is closed: builds, the repository
46
+ * journal, and operator sessions (SPEC §7.1.1) — every turn's content is one
47
+ * session-scoped stream. */
48
+ export type StreamScope =
49
+ | { kind: 'build'; build: string }
50
+ | { kind: 'repo'; repo: string }
51
+ | { kind: 'session'; session: string }
52
+
53
+ export type StreamStatus = 'open' | 'closed'
54
+ export type StreamOutcome = 'completed' | 'aborted'
55
+
56
+ /** Reference to the artifact the close deposited on the owning scope. */
57
+ export interface StreamArtifactRef {
58
+ kind: string
59
+ revision: number
60
+ blobRef: string
61
+ }
62
+
63
+ export interface StreamRecord {
64
+ id: string
65
+ scope: StreamScope
66
+ /** Caller-supplied label; presentation metadata only. */
67
+ label: string
68
+ format: typeof STREAM_FORMAT
69
+ status: StreamStatus
70
+ createdAt: string
71
+ closedAt?: string
72
+ outcome?: StreamOutcome
73
+ /** Present once closed: the finalized `UIMessage[]` document's artifact. */
74
+ artifact?: StreamArtifactRef
75
+ }
76
+
77
+ export interface StreamRead {
78
+ chunks: StreamChunk[]
79
+ status: StreamStatus
80
+ outcome?: StreamOutcome
81
+ artifact?: StreamArtifactRef
82
+ }
83
+
84
+ /** Appending a batch whose serialized size exceeds the ceiling. Typed so the
85
+ * remote server maps it to `413 {kind:'validation'}` like the artifact
86
+ * ceiling and the remote client rehydrates it; the message names the bound. */
87
+ export class StreamBatchTooLargeError extends Error {
88
+ constructor(
89
+ readonly bytes: number | undefined,
90
+ readonly maxBytes: number = STREAM_BATCH_MAX_BYTES,
91
+ message?: string,
92
+ ) {
93
+ super(message ?? `stream batch of ${bytes} bytes exceeds the ${maxBytes}-byte ceiling`)
94
+ this.name = 'StreamBatchTooLargeError'
95
+ }
96
+ }
97
+
98
+ /** Appending to a closed stream. Typed so the remote server maps it to
99
+ * `409 {kind:'conflict'}` and the remote client rehydrates it. The argument
100
+ * is the stream id locally, or the server's message over the wire. */
101
+ export class StreamClosedError extends Error {
102
+ constructor(streamOrMessage: string) {
103
+ super(
104
+ streamOrMessage.startsWith('stream "')
105
+ ? streamOrMessage
106
+ : `stream "${streamOrMessage}" is closed`,
107
+ )
108
+ this.name = 'StreamClosedError'
109
+ }
110
+ }
111
+
112
+ /** Append validation: a nonempty array of parts, each a JSON object whose
113
+ * `type` is a nonempty string. Throws a plain `Error` otherwise. */
114
+ export function validateStreamParts(parts: StreamPart[]): void {
115
+ if (!Array.isArray(parts) || parts.length === 0) {
116
+ throw new Error('stream parts must be a nonempty array')
117
+ }
118
+ for (const part of parts) {
119
+ if (typeof part !== 'object' || part === null || Array.isArray(part)) {
120
+ throw new Error('every stream part must be a JSON object')
121
+ }
122
+ if (typeof part.type !== 'string' || part.type.length === 0) {
123
+ throw new Error('every stream part must carry a nonempty string "type"')
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Serialized JSON size of a batch, in bytes — the value compared against
129
+ * `STREAM_BATCH_MAX_BYTES`. */
130
+ export function serializedBatchSize(parts: StreamPart[]): number {
131
+ return new TextEncoder().encode(JSON.stringify(parts)).byteLength
132
+ }
133
+
134
+ /** The read-wait bound: whole seconds, clamped above 30 and below 0. */
135
+ export function clampWaitSeconds(waitSeconds: number): number {
136
+ return Math.max(0, Math.min(MAX_STREAM_WAIT_SECONDS, Math.floor(waitSeconds)))
137
+ }
138
+
139
+ /**
140
+ * The close-time artifact input every adapter deposits: the assembled
141
+ * `UIMessage[]` document as JSON bytes, kind `stream:<streamId>`, with
142
+ * metadata naming the stream id, label, scope, outcome, chunk count, and
143
+ * dropped-part count. Shared so every adapter's finalized artifact is
144
+ * byte-identical for the same stream.
145
+ */
146
+ export function streamArtifactInput(
147
+ stream: string,
148
+ scope: StreamScope,
149
+ label: string,
150
+ outcome: StreamOutcome,
151
+ document: unknown,
152
+ chunkCount: number,
153
+ droppedPartCount: number,
154
+ ): {
155
+ kind: string
156
+ content: string
157
+ metadata: Record<string, unknown>
158
+ } {
159
+ return {
160
+ kind: `stream:${stream}`,
161
+ content: JSON.stringify(document),
162
+ metadata: {
163
+ stream,
164
+ label,
165
+ scope: { ...scope },
166
+ outcome,
167
+ chunkCount,
168
+ droppedPartCount,
169
+ },
170
+ }
171
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The shared read-wait loop (SPEC §7.6): when a read finds no newer chunk and
3
+ * the stream is open, the adapter honors the wait bound — returning no later
4
+ * than the bound, but as soon as a chunk is appended or the stream closes.
5
+ * The memory, SQLite, and PostgreSQL adapters all delegate here so wait
6
+ * semantics are adapter-uniform; the remote adapter instead delegates the
7
+ * wait to its backing store over one held-open request.
8
+ *
9
+ * A small poll loop rather than a waiter registry: the uniformity across
10
+ * synchronous (SQLite) and asynchronous (PostgreSQL) backends is worth more
11
+ * than wake-on-write precision. The loop serves two cadences: held *stream*
12
+ * reads are presentation content and poll at the ~25 ms `STREAM_WAIT_POLL_MS`;
13
+ * held *event* reads (build, repository, session — AUT-334/381/383) take the
14
+ * one-second `EVENT_WAIT_POLL_MS` budget when the adapter passes it per call
15
+ * (see `EVENT_WAIT_POLL_MS` for which adapters do). Closed streams never wait.
16
+ *
17
+ * Both loops accept an optional `signal` (AUT-380): when it aborts, the hold
18
+ * ends promptly — the inter-poll sleep resolves early, the loop stops, and the
19
+ * read resolves with its current result instead of polling to the wait bound.
20
+ * This is what lets a remote server cancel a disconnected peer's backing read
21
+ * rather than let it poll an unobserved database. Resolving (never rejecting)
22
+ * keeps every caller on one code path: callers treat the empty result exactly
23
+ * as they treat the hold expiring.
24
+ */
25
+ import { clampWaitSeconds, type StreamRead } from './types'
26
+
27
+ export const STREAM_WAIT_POLL_MS = 25
28
+
29
+ /**
30
+ * The one-second held-event poll budget (the hosted per-query budget,
31
+ * AUT-334/381/383): held event reads — build, repository, and session —
32
+ * re-query at most once per second, so an append is observed at the next
33
+ * poll, typically within about one second. Held *stream* reads are excluded:
34
+ * they are presentation content and stay at the ~25 ms `STREAM_WAIT_POLL_MS`
35
+ * cadence. This deliberately diverges from `STREAM_WAIT_POLL_MS`; adapters
36
+ * pass it as `pollMs` on their event-family calls to `readEventsWithWait` —
37
+ * the PostgreSQL and memory stores do on all three families (AUT-334/381/383).
38
+ * The SQLite store has not adopted the budget (out of scope for AUT-383), so
39
+ * its held event reads fall back to the stream default until it does.
40
+ *
41
+ * The PostgreSQL adapter treats the bound as nominal, not hard: a worst-case
42
+ * ≤1 s guarantee would need Postgres LISTEN/NOTIFY wake-on-append — one
43
+ * dedicated connection per held request on a pooled provider (Neon) — a cost
44
+ * considered and declined (AUT-334). Held event reads there re-query the
45
+ * database at most once per second, so an append by another connection is
46
+ * observed at the next poll: typically within about one second, worst case
47
+ * one poll interval plus the query round-trip.
48
+ */
49
+ export const EVENT_WAIT_POLL_MS = 1000
50
+
51
+ /** Sleep `ms`, or return early when `signal` aborts. Resolves (never
52
+ * rejects) on either path; the abort listener is removed on both so a
53
+ * long-held read cannot leak listeners. No signal (or an already-aborted
54
+ * one) never waits. */
55
+ function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
56
+ if (signal === undefined) return Bun.sleep(ms)
57
+ if (signal.aborted) return Promise.resolve()
58
+ return new Promise<void>((resolve) => {
59
+ let settled = false
60
+ const finish = (): void => {
61
+ if (settled) return
62
+ settled = true
63
+ clearTimeout(timer)
64
+ signal.removeEventListener('abort', onAbort)
65
+ resolve()
66
+ }
67
+ const timer = setTimeout(finish, ms)
68
+ const onAbort = (): void => finish()
69
+ signal.addEventListener('abort', onAbort, { once: true })
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Read once, then poll until newer chunks arrive, the stream closes, the
75
+ * clamped wall-clock deadline passes, or `signal` aborts. `read` must
76
+ * re-query the store on every call; a throw from it propagates immediately.
77
+ * An abort ends the hold with the current result (a pre-aborted signal still
78
+ * performs the initial read — reads are cheap and side-effect-free — but
79
+ * never waits).
80
+ */
81
+ export async function readStreamWithWait(opts: {
82
+ read: () => Promise<StreamRead>
83
+ waitSeconds?: number
84
+ pollMs?: number
85
+ signal?: AbortSignal
86
+ }): Promise<StreamRead> {
87
+ const pollMs = opts.pollMs ?? STREAM_WAIT_POLL_MS
88
+ const deadline = Date.now() + clampWaitSeconds(opts.waitSeconds ?? 0) * 1000
89
+ let result = await opts.read()
90
+ while (
91
+ !opts.signal?.aborted &&
92
+ result.status === 'open' &&
93
+ result.chunks.length === 0 &&
94
+ Date.now() < deadline
95
+ ) {
96
+ await sleepWithAbort(pollMs, opts.signal)
97
+ if (opts.signal?.aborted) break
98
+ result = await opts.read()
99
+ }
100
+ return result
101
+ }
102
+
103
+ /**
104
+ * The event-read variant of the shared wait loop: read once, then poll until
105
+ * newer events arrive, the clamped deadline passes, or `signal` aborts.
106
+ * Session event reads (§7.1.1) honor the same clamp/early-return rules as
107
+ * stream reads so the operator UI's poll loop cannot drift between the two;
108
+ * there is no "closed" state to short-circuit on — an event log is always
109
+ * open. An abort ends the hold with the current result (a pre-aborted signal
110
+ * still performs the initial read but never waits).
111
+ */
112
+ export async function readEventsWithWait<T>(opts: {
113
+ read: () => Promise<T[]>
114
+ waitSeconds?: number
115
+ pollMs?: number
116
+ signal?: AbortSignal
117
+ }): Promise<T[]> {
118
+ const pollMs = opts.pollMs ?? STREAM_WAIT_POLL_MS
119
+ const deadline = Date.now() + clampWaitSeconds(opts.waitSeconds ?? 0) * 1000
120
+ let result = await opts.read()
121
+ while (result.length === 0 && !opts.signal?.aborted && Date.now() < deadline) {
122
+ await sleepWithAbort(pollMs, opts.signal)
123
+ if (opts.signal?.aborted) break
124
+ result = await opts.read()
125
+ }
126
+ return result
127
+ }