@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Ticket assets (SPEC §7.1): named, versioned bundles of files attached to a
3
+ * ticket and kept in the BuildStore, keyed by `(repo, ticketId, kind, name)`.
4
+ * The ticket source never stores the contents; it only gets a note through
5
+ * `TicketSource.comment()` (SPEC §13).
6
+ *
7
+ * This module is the shared, adapter-independent half: the types, the limits,
8
+ * and the one validator every adapter calls before any write.
9
+ */
10
+
11
+ import type { AbEvent } from '../events/catalog'
12
+ import type { PinnedAsset } from '../events/payloads'
13
+ import { contentHash, type BlobStore, type BuildStore } from './types'
14
+
15
+ export type { PinnedAsset }
16
+
17
+ /** Total file bytes per revision (25 MiB). */
18
+ export const TICKET_ASSET_MAX_BYTES = 25 * 1024 * 1024
19
+ /** Files plus directories per revision. */
20
+ export const TICKET_ASSET_MAX_ENTRIES = 1000
21
+ /** UTF-8 bytes per entry path. */
22
+ export const TICKET_ASSET_MAX_PATH_BYTES = 256
23
+ /** UTF-8 bytes per path segment. */
24
+ export const TICKET_ASSET_MAX_SEGMENT_BYTES = 128
25
+ /** Kind and name length. */
26
+ export const TICKET_ASSET_MAX_LABEL_LENGTH = 64
27
+
28
+ /** Worst-case JSON envelope around the entries of one put request. */
29
+ export const WIRE_ENVELOPE_BYTES = 1024
30
+ /** Worst-case JSON per entry besides base64 content: a maximum-length path
31
+ * plus keys, quotes, commas, the type tag, and base64 padding. */
32
+ export const WIRE_ENTRY_BYTES = TICKET_ASSET_MAX_PATH_BYTES + 64
33
+
34
+ export type TicketAssetLayout = 'file' | 'tree'
35
+
36
+ /** An entry is a file or an explicit directory. Paths are relative and
37
+ * '/'-separated (`index.html`, `img/a.png`, `img/empty`). */
38
+ export type TicketAssetEntryInput =
39
+ | { type: 'file'; path: string; content: Uint8Array }
40
+ | { type: 'dir'; path: string }
41
+
42
+ export interface TicketAssetInput {
43
+ kind: string
44
+ name: string
45
+ layout: TicketAssetLayout
46
+ entries: TicketAssetEntryInput[]
47
+ }
48
+
49
+ export type TicketAssetEntry =
50
+ | { type: 'file'; path: string; size: number; blobRef: string }
51
+ | { type: 'dir'; path: string }
52
+
53
+ export interface TicketAssetMeta {
54
+ repo: string
55
+ ticketId: string
56
+ kind: string
57
+ name: string
58
+ /** 0-based per `(repo, ticketId, kind, name)`. A removal consumes a number
59
+ * too (a tombstone), so revisions may skip. */
60
+ revision: number
61
+ layout: TicketAssetLayout
62
+ /** Total file bytes. */
63
+ size: number
64
+ entries: TicketAssetEntry[]
65
+ createdAt: string
66
+ }
67
+
68
+ /** What `listTicketAssets` returns on every adapter: the metadata without
69
+ * the manifest. */
70
+ export interface TicketAssetSummary {
71
+ repo: string
72
+ ticketId: string
73
+ kind: string
74
+ name: string
75
+ revision: number
76
+ layout: TicketAssetLayout
77
+ size: number
78
+ fileCount: number
79
+ dirCount: number
80
+ createdAt: string
81
+ }
82
+
83
+ export type TicketAssetContentEntry =
84
+ | { type: 'file'; path: string; content: Uint8Array }
85
+ | { type: 'dir'; path: string }
86
+
87
+ export interface TicketAsset {
88
+ meta: TicketAssetMeta
89
+ entries: TicketAssetContentEntry[]
90
+ }
91
+
92
+ export interface TicketAssetLimits {
93
+ maxBytes: number
94
+ maxEntries: number
95
+ }
96
+
97
+ export const DEFAULT_TICKET_ASSET_LIMITS: TicketAssetLimits = {
98
+ maxBytes: TICKET_ASSET_MAX_BYTES,
99
+ maxEntries: TICKET_ASSET_MAX_ENTRIES,
100
+ }
101
+
102
+ export class TicketAssetValidationError extends Error {
103
+ constructor(message: string) {
104
+ super(message)
105
+ this.name = 'TicketAssetValidationError'
106
+ }
107
+ }
108
+
109
+ export function summarizeTicketAsset(meta: TicketAssetMeta): TicketAssetSummary {
110
+ let fileCount = 0
111
+ let dirCount = 0
112
+ for (const entry of meta.entries) {
113
+ if (entry.type === 'file') fileCount += 1
114
+ else dirCount += 1
115
+ }
116
+ return {
117
+ repo: meta.repo,
118
+ ticketId: meta.ticketId,
119
+ kind: meta.kind,
120
+ name: meta.name,
121
+ revision: meta.revision,
122
+ layout: meta.layout,
123
+ size: meta.size,
124
+ fileCount,
125
+ dirCount,
126
+ createdAt: meta.createdAt,
127
+ }
128
+ }
129
+
130
+ function formatBytes(bytes: number): string {
131
+ const mib = bytes / (1024 * 1024)
132
+ if (bytes >= 1024 * 1024 && Number.isInteger(mib)) return `${mib} MiB`
133
+ if (bytes >= 1024 * 1024) return `${mib.toFixed(1)} MiB`
134
+ if (bytes >= 1024 && bytes % 1024 === 0) return `${bytes / 1024} KiB`
135
+ return `${bytes} bytes`
136
+ }
137
+
138
+ /** The limit-naming error for an over-size revision. `size` is the total file
139
+ * bytes the caller tried to store. */
140
+ export function ticketAssetSizeError(size: number, limit: number): TicketAssetValidationError {
141
+ return new TicketAssetValidationError(
142
+ `ticket asset exceeds the ${limit}-byte (${formatBytes(limit)}) limit: ${size} bytes`,
143
+ )
144
+ }
145
+
146
+ export function ticketAssetEntriesError(count: number, limit: number): TicketAssetValidationError {
147
+ return new TicketAssetValidationError(
148
+ `ticket asset exceeds the ${limit}-entry limit: ${count} entries (files plus directories)`,
149
+ )
150
+ }
151
+
152
+ /** The effective limits of a store whose requests and responses can carry at
153
+ * most `maxRequestBytes`. Paths and entry counts are bounded, so the worst-case
154
+ * wire overhead is a constant; base64 inflates content by 4/3. A safe lower
155
+ * bound: the client's exact-size check is the final authority. */
156
+ export function effectiveTicketAssetLimits(maxRequestBytes?: number): TicketAssetLimits {
157
+ if (maxRequestBytes === undefined) return { ...DEFAULT_TICKET_ASSET_LIMITS }
158
+ const overhead = WIRE_ENVELOPE_BYTES + TICKET_ASSET_MAX_ENTRIES * WIRE_ENTRY_BYTES
159
+ return {
160
+ maxBytes: Math.min(
161
+ TICKET_ASSET_MAX_BYTES,
162
+ Math.max(0, Math.floor(((maxRequestBytes - overhead) * 3) / 4)),
163
+ ),
164
+ maxEntries: TICKET_ASSET_MAX_ENTRIES,
165
+ }
166
+ }
167
+
168
+ const LABEL_PATTERN = /^[A-Za-z0-9._-]+$/
169
+
170
+ export function validateTicketAssetLabel(field: 'kind' | 'name', value: string): void {
171
+ if (typeof value !== 'string' || value.trim() === '') {
172
+ throw new TicketAssetValidationError(`ticket asset ${field} is required`)
173
+ }
174
+ if (value.length > TICKET_ASSET_MAX_LABEL_LENGTH) {
175
+ throw new TicketAssetValidationError(
176
+ `ticket asset ${field} must be at most ${TICKET_ASSET_MAX_LABEL_LENGTH} characters`,
177
+ )
178
+ }
179
+ if (!LABEL_PATTERN.test(value) || value === '.' || value === '..') {
180
+ throw new TicketAssetValidationError(
181
+ `ticket asset ${field} "${value}" may contain only letters, digits, '.', '_' and '-'`,
182
+ )
183
+ }
184
+ }
185
+
186
+ const encoder = new TextEncoder()
187
+
188
+ /** One entry path: relative, '/'-separated, no empty/`.`/`..` segments, no
189
+ * NUL, bounded. */
190
+ export function validateTicketAssetPath(path: string): void {
191
+ if (typeof path !== 'string' || path === '') {
192
+ throw new TicketAssetValidationError('ticket asset entry path is required')
193
+ }
194
+ if (path.includes('\0')) {
195
+ throw new TicketAssetValidationError(`ticket asset entry path "${path}" contains a NUL byte`)
196
+ }
197
+ if (path.includes('\\')) {
198
+ throw new TicketAssetValidationError(
199
+ `ticket asset entry path "${path}" must use '/' separators`,
200
+ )
201
+ }
202
+ if (encoder.encode(path).length > TICKET_ASSET_MAX_PATH_BYTES) {
203
+ throw new TicketAssetValidationError(
204
+ `ticket asset entry path exceeds ${TICKET_ASSET_MAX_PATH_BYTES} bytes: "${path.slice(0, 40)}…"`,
205
+ )
206
+ }
207
+ if (path.startsWith('/')) {
208
+ throw new TicketAssetValidationError(`ticket asset entry path "${path}" must be relative`)
209
+ }
210
+ for (const segment of path.split('/')) {
211
+ if (segment === '' || segment === '.' || segment === '..') {
212
+ throw new TicketAssetValidationError(
213
+ `ticket asset entry path "${path}" has an empty, '.' or '..' segment`,
214
+ )
215
+ }
216
+ if (encoder.encode(segment).length > TICKET_ASSET_MAX_SEGMENT_BYTES) {
217
+ throw new TicketAssetValidationError(
218
+ `ticket asset entry path segment exceeds ${TICKET_ASSET_MAX_SEGMENT_BYTES} bytes in "${path.slice(0, 40)}…"`,
219
+ )
220
+ }
221
+ }
222
+ }
223
+
224
+ /** Shape and structure of a manifest, independent of content: shared by put
225
+ * validation and the remote server's check of an incoming request. Returns
226
+ * the total file bytes. */
227
+ export function validateTicketAssetStructure(
228
+ input: {
229
+ kind: string
230
+ name: string
231
+ layout: TicketAssetLayout
232
+ entries: ({ type: 'file'; path: string; size: number } | { type: 'dir'; path: string })[]
233
+ },
234
+ limits: TicketAssetLimits = DEFAULT_TICKET_ASSET_LIMITS,
235
+ ): number {
236
+ validateTicketAssetLabel('kind', input.kind)
237
+ validateTicketAssetLabel('name', input.name)
238
+ if (input.layout !== 'file' && input.layout !== 'tree') {
239
+ throw new TicketAssetValidationError(`ticket asset layout must be 'file' or 'tree'`)
240
+ }
241
+ const files = input.entries.filter((entry) => entry.type === 'file')
242
+ if (input.layout === 'file') {
243
+ if (input.entries.length !== 1 || files.length !== 1) {
244
+ throw new TicketAssetValidationError(
245
+ `a 'file' ticket asset has exactly one file entry and no directories`,
246
+ )
247
+ }
248
+ if (files[0]?.path.includes('/')) {
249
+ throw new TicketAssetValidationError(`a 'file' ticket asset's path is its filename`)
250
+ }
251
+ }
252
+ if (input.entries.length > limits.maxEntries) {
253
+ throw ticketAssetEntriesError(input.entries.length, limits.maxEntries)
254
+ }
255
+ const kinds = new Map<string, 'file' | 'dir'>()
256
+ let size = 0
257
+ for (const entry of input.entries) {
258
+ if (entry.type !== 'file' && entry.type !== 'dir') {
259
+ throw new TicketAssetValidationError(`ticket asset entry type must be 'file' or 'dir'`)
260
+ }
261
+ validateTicketAssetPath(entry.path)
262
+ if (kinds.has(entry.path)) {
263
+ throw new TicketAssetValidationError(`duplicate ticket asset entry path "${entry.path}"`)
264
+ }
265
+ kinds.set(entry.path, entry.type)
266
+ if (entry.type === 'file') size += entry.size
267
+ }
268
+ for (const [path] of kinds) {
269
+ const segments = path.split('/')
270
+ for (let end = 1; end < segments.length; end += 1) {
271
+ const ancestor = segments.slice(0, end).join('/')
272
+ if (kinds.get(ancestor) === 'file') {
273
+ throw new TicketAssetValidationError(
274
+ `ticket asset entry "${path}" has a file as its ancestor directory "${ancestor}"`,
275
+ )
276
+ }
277
+ }
278
+ }
279
+ if (size > limits.maxBytes) throw ticketAssetSizeError(size, limits.maxBytes)
280
+ return size
281
+ }
282
+
283
+ /** The one validator every adapter calls before any write. Throws
284
+ * `TicketAssetValidationError`; returns the total file bytes. */
285
+ export function validateTicketAssetInput(
286
+ input: TicketAssetInput,
287
+ limits: TicketAssetLimits = DEFAULT_TICKET_ASSET_LIMITS,
288
+ ): number {
289
+ return validateTicketAssetStructure(
290
+ {
291
+ kind: input.kind,
292
+ name: input.name,
293
+ layout: input.layout,
294
+ entries: input.entries.map((entry) =>
295
+ entry.type === 'file'
296
+ ? { type: 'file', path: entry.path, size: entry.content.byteLength }
297
+ : { type: 'dir', path: entry.path },
298
+ ),
299
+ },
300
+ limits,
301
+ )
302
+ }
303
+
304
+ /** Write each file's bytes to the blob store (content-addressed) and build the
305
+ * manifest. Call only after `validateTicketAssetInput`. */
306
+ export async function storeTicketAssetBlobs(
307
+ blobs: BlobStore,
308
+ entries: TicketAssetEntryInput[],
309
+ ): Promise<TicketAssetEntry[]> {
310
+ const manifest: TicketAssetEntry[] = []
311
+ for (const entry of entries) {
312
+ if (entry.type === 'dir') {
313
+ manifest.push({ type: 'dir', path: entry.path })
314
+ continue
315
+ }
316
+ const blobRef = contentHash(entry.content)
317
+ await blobs.put(blobRef, entry.content)
318
+ manifest.push({ type: 'file', path: entry.path, size: entry.content.byteLength, blobRef })
319
+ }
320
+ return manifest
321
+ }
322
+
323
+ /** Read the bytes of one manifest file; a missing blob is store corruption. */
324
+ export async function readTicketAssetBlob(
325
+ blobs: BlobStore,
326
+ meta: TicketAssetMeta,
327
+ entry: { path: string; blobRef: string },
328
+ ): Promise<Uint8Array> {
329
+ const bytes = await blobs.get(entry.blobRef)
330
+ if (!bytes) {
331
+ throw new Error(
332
+ `ticket asset ${meta.kind}/${meta.name} revision ${meta.revision}: blob for "${entry.path}" is missing`,
333
+ )
334
+ }
335
+ return bytes
336
+ }
337
+
338
+ /** Load a manifest's full content (every adapter's `getTicketAsset` tail). */
339
+ export async function loadTicketAsset(
340
+ blobs: BlobStore,
341
+ meta: TicketAssetMeta,
342
+ ): Promise<TicketAsset> {
343
+ const entries: TicketAssetContentEntry[] = []
344
+ for (const entry of meta.entries) {
345
+ entries.push(
346
+ entry.type === 'dir'
347
+ ? { type: 'dir', path: entry.path }
348
+ : {
349
+ type: 'file',
350
+ path: entry.path,
351
+ content: await readTicketAssetBlob(blobs, meta, entry),
352
+ },
353
+ )
354
+ }
355
+ return { meta: structuredClone(meta), entries }
356
+ }
357
+
358
+ /** What a build pins of one asset revision: the summary minus the ticket
359
+ * coordinates and timestamp, which the build record already carries. */
360
+ export function pinnedAssetOf(summary: TicketAssetSummary): PinnedAsset {
361
+ return {
362
+ kind: summary.kind,
363
+ name: summary.name,
364
+ revision: summary.revision,
365
+ layout: summary.layout,
366
+ size: summary.size,
367
+ fileCount: summary.fileCount,
368
+ dirCount: summary.dirCount,
369
+ }
370
+ }
371
+
372
+ /** The set of ticket assets to freeze for a claim or a spec re-pin: every
373
+ * current (live) asset of the ticket, in `listTicketAssets` order. */
374
+ export async function samplePinnedAssets(
375
+ store: Pick<BuildStore, 'listTicketAssets'>,
376
+ repo: string,
377
+ ticketId: string,
378
+ ): Promise<PinnedAsset[]> {
379
+ return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
380
+ }
381
+
382
+ /** Latest-pinned revision of `kind/name` as the build's events record it, or
383
+ * (with `rev`) the revision when any pin in the log named it. */
384
+ export function findPinnedRevision(
385
+ events: AbEvent[],
386
+ kind: string,
387
+ name: string,
388
+ rev?: number,
389
+ ): number | undefined {
390
+ let latest: number | undefined
391
+ for (const event of events) {
392
+ if (event.type !== 'build.created' && event.type !== 'spec.revised') continue
393
+ const assets = event.payload.assets
394
+ if (assets === undefined) continue
395
+ if (rev !== undefined) {
396
+ if (assets.some((a) => a.kind === kind && a.name === name && a.revision === rev)) return rev
397
+ continue
398
+ }
399
+ // Each pin event replaces the set, so the newest event that carries a set
400
+ // decides; absent from that set means unpinned.
401
+ latest = assets.find((a) => a.kind === kind && a.name === name)?.revision
402
+ }
403
+ return latest
404
+ }
405
+
406
+ /** `BuildStore.getPinnedTicketAsset` for any adapter: read the build's own
407
+ * events to find the pinned revision, then fetch exactly that revision from the
408
+ * ticket's assets. Earlier revisions stay retrievable after a replace or a
409
+ * removal (SPEC §7.1), so the bytes can never drift, and no lease or liveness is
410
+ * needed, so a finished build still answers. Null when the build never pinned
411
+ * `kind/name` (or the revision). Rejects an unknown build. */
412
+ export async function resolvePinnedAsset(
413
+ store: Pick<BuildStore, 'getBuild' | 'getEvents' | 'getTicketAsset'>,
414
+ slug: string,
415
+ kind: string,
416
+ name: string,
417
+ rev?: number,
418
+ ): Promise<TicketAsset | null> {
419
+ const record = await store.getBuild(slug)
420
+ if (record === null) throw new Error(`unknown build "${slug}"`)
421
+ if (record.ticket === undefined) return null
422
+ const revision = findPinnedRevision(await store.getEvents(slug), kind, name, rev)
423
+ if (revision === undefined) return null
424
+ return store.getTicketAsset(record.repo, record.ticket.id, kind, name, revision)
425
+ }
@@ -24,7 +24,15 @@ import type {
24
24
  SessionEventType,
25
25
  SessionEventWrite,
26
26
  } from '../events/sessions'
27
+ export type { SessionEventEnvelope }
27
28
  import type { TicketRef } from '../ontology'
29
+ import type {
30
+ TicketAsset,
31
+ TicketAssetInput,
32
+ TicketAssetLimits,
33
+ TicketAssetMeta,
34
+ TicketAssetSummary,
35
+ } from './ticket-assets'
28
36
  import type {
29
37
  StreamChunk,
30
38
  StreamOutcome,
@@ -64,6 +72,12 @@ export interface NewBuildInput {
64
72
  repoOrigin?: string
65
73
  ticket?: TicketRef
66
74
  branch?: string
75
+ /** The build's `build.created` fact, appended as seq 1 in the same atomic
76
+ * step as the record: both exist or neither does, so the claim-time facts it
77
+ * freezes (ticket assets, SPEC §6.3) can never be lost to a crash between
78
+ * the two writes. An invalid payload, or any other event type, rejects and
79
+ * creates nothing. */
80
+ created?: EventWrite<'build.created'>
67
81
  }
68
82
 
69
83
  export interface ArtifactMeta {
@@ -77,6 +91,32 @@ export interface ArtifactMeta {
77
91
  createdAt: string
78
92
  }
79
93
 
94
+ /** The projection of one build's event log onto exactly the two facts an
95
+ * operator dashboard needs from every build (AUT-487): the latest terminal
96
+ * fact's kind — `done` for `build.completed`, `aborted` for `build.aborted`,
97
+ * in-order overwrite, exactly `reduceBuild`'s terminal rule — and the seqs of
98
+ * the log's `observation.recorded` occurrences. `terminal` is absent while
99
+ * the log carries neither terminal fact (a queued, running, paused, blocked,
100
+ * or mid-cleanup build). Everything else a dashboard shows comes from the
101
+ * journal, the build records, and the full histories of just the builds that
102
+ * render rows. Nothing is persisted: every adapter derives the digest from
103
+ * the event log on each call, so the log stays the sole authority and the
104
+ * digest can always be discarded and rebuilt. */
105
+ export interface BuildDigest {
106
+ slug: string
107
+ terminal?: 'done' | 'aborted'
108
+ /** Ascending-seq `{seq, ts}` pairs for every `observation.recorded` in the
109
+ * build's log; `ts` is the event envelope timestamp (AUT-521: the harvest
110
+ * pressure gate measures drift from the oldest unclaimed occurrence's ts,
111
+ * so per-occurrence timestamps travel with the digest). */
112
+ observations: { seq: number; ts: string }[]
113
+ /** Envelope ts of the log's latest `pr.merged` event, absent when the log
114
+ * has none (AUT-521: latest-wins suffices for drift, which counts a build
115
+ * when any of its merges is strictly after the oldest unclaimed
116
+ * observation). */
117
+ merged?: string
118
+ }
119
+
80
120
  export interface RepositoryRecord {
81
121
  repo: string
82
122
  createdAt: string
@@ -103,7 +143,8 @@ export interface RepositoryArtifact {
103
143
  /** An operator session: hosted-only durable orchestrator-conversation state
104
144
  * (SPEC §7.1.1). The record carries only identity and timestamps; everything
105
145
  * else — status, open turn, pending approval, wake settings — is a reduction
106
- * of the session's own event log (sessions/reducer.ts), never a column. */
146
+ * of the session's own event log (the hosted package's session-reducer.ts),
147
+ * never a column. */
107
148
  export interface SessionRecord {
108
149
  /** Store-assigned (`os_<uuid>`). */
109
150
  id: string
@@ -257,6 +298,18 @@ export interface BuildStore {
257
298
  opts?: { waitSeconds?: number; signal?: AbortSignal },
258
299
  ): Promise<AbEvent[]>
259
300
 
301
+ /** One entry for EVERY build whose record's repo is `repo` (completeness is
302
+ * contractual: a freshly created build whose log holds none of the
303
+ * digest-relevant event types still gets its entry, with empty
304
+ * `observations` and no `terminal`), keyed by slug. A read-only, repo-scoped
305
+ * batch query shaped like `listBuilds`: it performs no `ensureRepo`, never
306
+ * writes, and an unknown or build-less repo answers an empty map rather
307
+ * than rejecting. Derived from the event log on every call — the log is the
308
+ * sole authority, so there is nothing to refresh or invalidate. The
309
+ * derivation is the shared `reduceBuildDigest` (store/digest.ts), so every
310
+ * adapter answers identical results for identical logs. */
311
+ getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>>
312
+
260
313
  putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta>
261
314
  /** Latest revision when `rev` is omitted; null if kind (or rev) absent. */
262
315
  getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null>
@@ -305,9 +358,62 @@ export interface BuildStore {
305
358
  sinceSeq?: number,
306
359
  opts?: { waitSeconds?: number; signal?: AbortSignal },
307
360
  ): Promise<RepositoryEvent[]>
361
+ /** Bounded repository-journal read for stateless full-state readers
362
+ * (AUT-489): every durable fact (REPOSITORY_STATE_EVENT_TYPES) plus — when
363
+ * the journal has one — the tail from the latest `dispatcher.run-started`.
364
+ * Reducing this subset with the state readers yields exactly what a full
365
+ * replay yields (`projectRepositoryStateEvents` is the normative oracle).
366
+ * Unknown repo rejects like `getRepoEvents`. */
367
+ getRepoStateEvents(repo: string): Promise<RepositoryEvent[]>
308
368
  putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta>
309
369
  getRepoArtifact(repo: string, kind: string, rev?: number): Promise<RepositoryArtifact | null>
310
370
  listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]>
371
+ // ── Ticket assets (SPEC §7.1 — files attached to a ticket) ─────────────
372
+ // Keyed by (repo, ticketId, kind, name); revisions are 0-based. The store
373
+ // owns the bytes, the ticket source only ever gets a note (SPEC §13).
374
+ /** The effective ceilings this store accepts for `repo` (a remote
375
+ * deployment may advertise lower ones than the defaults). */
376
+ ticketAssetLimits(repo: string): Promise<TicketAssetLimits>
377
+ /** Validate, then store a new revision. Over-limit or invalid input rejects
378
+ * with `TicketAssetValidationError` and stores nothing. */
379
+ putTicketAsset(repo: string, ticketId: string, asset: TicketAssetInput): Promise<TicketAssetMeta>
380
+ /** Latest live revision when `rev` is omitted; null when the asset is
381
+ * absent, removed (latest row is a tombstone), or `rev` is not a real
382
+ * revision. An explicit `rev` of an earlier revision keeps working after a
383
+ * removal, so a build that pinned it still retrieves it. */
384
+ getTicketAsset(
385
+ repo: string,
386
+ ticketId: string,
387
+ kind: string,
388
+ name: string,
389
+ rev?: number,
390
+ ): Promise<TicketAsset | null>
391
+ /** The ticket asset `kind/name` exactly as build `slug` froze it (SPEC
392
+ * §6.3): the latest pinned revision, or with `rev` any revision the build
393
+ * ever pinned. Reads the build's own events, never the ticket's current
394
+ * state, so it is stable after a replace or removal and works for finished
395
+ * builds. Null when the build never pinned it. Rejects an unknown build. */
396
+ getPinnedTicketAsset(
397
+ slug: string,
398
+ kind: string,
399
+ name: string,
400
+ rev?: number,
401
+ ): Promise<TicketAsset | null>
402
+ /** Default: the latest live revision per (kind, name). `revisions: true`:
403
+ * every real revision (no tombstones). Summaries carry no manifest. */
404
+ listTicketAssets(
405
+ repo: string,
406
+ ticketId: string,
407
+ opts?: { revisions?: boolean },
408
+ ): Promise<TicketAssetSummary[]>
409
+ /** Tombstone the asset (the next revision number, empty manifest). Returns
410
+ * the removed revision's meta, or null when no live asset exists. */
411
+ removeTicketAsset(
412
+ repo: string,
413
+ ticketId: string,
414
+ kind: string,
415
+ name: string,
416
+ ): Promise<TicketAssetMeta | null>
311
417
  claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean>
312
418
  heartbeatRepo(repo: string, holder: string): Promise<boolean>
313
419
  releaseRepoLease(repo: string, holder: string): Promise<void>
@@ -339,6 +445,21 @@ export interface BuildStore {
339
445
  id: string,
340
446
  event: SessionEventWrite<T>,
341
447
  ): Promise<SessionEventEnvelope<T>>
448
+ /** Atomically append one validated session event only when the session's
449
+ * current sequence equals `expectedSeq` (0 would only match a session with
450
+ * no facts — impossible, since `session.created` is always seq 1). Returns
451
+ * null when the session has advanced; a comparison miss must not mutate the
452
+ * log or the record's `updatedAt`. The three turn-start sites (message
453
+ * route, answer route, dispatcher tick) race on the same idle session, and
454
+ * two concurrent `turn.started` facts would break the single-open-turn
455
+ * invariant — this is the sessions mirror of the build log's
456
+ * `appendIfCurrent`. Invalid events and unknown sessions reject just as
457
+ * they do for `appendSessionEvent`. */
458
+ appendSessionEventIfCurrent<T extends SessionEventType>(
459
+ id: string,
460
+ expectedSeq: number,
461
+ event: SessionEventWrite<T>,
462
+ ): Promise<SessionEventEnvelope<T> | null>
342
463
  /** Events with seq strictly greater than `sinceSeq` (default 0), in order.
343
464
  * When nothing newer exists, an adapter honors `waitSeconds` with the
344
465
  * same clamp/early-return rules as stream reads (§7.6): whole seconds,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Public testing entry point: the deterministic doubles, CLI entry points,
3
+ * and the end-to-end harness that out-of-tree packages' tests build against
4
+ * (the hosted store-service suite is the in-repo consumer). Production code
5
+ * never imports from here — it is test-facing surface, like plugin-sdk's
6
+ * fakes, just CLI-shaped.
7
+ */
8
+ // The plugin-sdk's contract suites, fakes, and memory stores are the shared
9
+ // testing vocabulary; re-export so `@defrex/autobuild/testing` is one import.
10
+ export * from '../plugin-sdk'
11
+ export { manualClock, steppingClock } from './fixed'
12
+ export { agentActor, DISPATCHER, KERNEL, humanActor, type Via } from '../events/envelope'
13
+ export { EventValidationError } from '../events/catalog'
14
+ export type { EscalationSource } from '../ontology'
15
+ export { abDispatch, type DispatchOpts } from '../cli/dispatch'
16
+ // CLI entry points for the hosted-dispatcher integration test: programmatic
17
+ // `ab` invocation and its environment resolution.
18
+ export { resolveCliEnv } from '../cli/env'
19
+ export { runCli } from '../cli/main'
20
+ // Deterministic id doubles for the hosted-dispatcher integration test.
21
+ export { randomUuids, sequentialIds, type IdSource } from '../ids'
22
+ export { abWatch } from '../cli/watch'
23
+ export { abTicket, type TicketSourceFactory } from '../cli/ticket'
24
+ export { createTerminalModeController } from '../cli/terminal-restore'
25
+ export { abBuildControl, type BuildControlAction } from '../cli/build-control'
26
+ export { abBulkControl } from '../cli/bulk-control'
27
+ export { createTicketSource } from '../ports/tickets/create'
28
+ export { createOperatorSandboxService } from '../operator/sandbox'
29
+ export { parseConfig } from '../config/load'
30
+ export { spawnExec, type Exec } from '../ports/workspace/git-worktree'
31
+ // Durable guest-launch state readers + the in-process guest runner for the
32
+ // hosted-dispatcher integration test's GuestExecution double, which supervises
33
+ // a real BuildRunner exactly as a real sandbox execution would.
34
+ export {
35
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
36
+ diagnosticArtifact,
37
+ parseEffectiveBuildConfig,
38
+ selectOpenWorkspace,
39
+ } from '../processes/build-execution-state'
40
+ export {
41
+ BuildRunner,
42
+ LeaseHeldError,
43
+ SetupFailureError,
44
+ } from '../processes/build-runner'
45
+ export { readEventsWithWait } from '../store/streams/wait'
46
+ export { textContent } from '../store/types'
47
+ export {
48
+ CONFIG_TOML,
49
+ GIT_ID,
50
+ git,
51
+ happyHandlers,
52
+ makeHarness,
53
+ readyTicket,
54
+ typesOf,
55
+ writeFileIn,
56
+ type Cli,
57
+ type E2eHarness,
58
+ type SkillHandlers,
59
+ } from '../integration/harness'
@@ -27,7 +27,11 @@ goes nowhere.
27
27
  fake or mock are not contract evidence because they can repeat the
28
28
  author's assumption.
29
29
  - **Spec conformance** — every acceptance criterion met; nothing beyond
30
- the spec's scope smuggled in.
30
+ the spec's scope smuggled in. A pinned `design` asset (listed under
31
+ `assets` in `.ab/context.json`, files under `.ab/assets/<kind>/<name>/`)
32
+ is a contract on observable visual and reference outcomes, not on implementation: judge the rendered output against it wherever the code lets you
33
+ check, and treat a divergence as a finding. A gap in the asset is not a
34
+ reason to invent requirements.
31
35
  - **Tests** — do the new tests exercise the seams this change created or
32
36
  moved? Would they fail if the change were wrong?
33
37
  - **Fit** — matches the codebase's idioms; no needless indirection.