@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -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/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -10,7 +10,7 @@
10
10
  * synchronous (SQLite) and asynchronous (PostgreSQL) backends is worth more
11
11
  * than wake-on-write precision. The loop serves two cadences: held *stream*
12
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
13
+ * held *event* reads (build, repository, session — AUT-334/381/383/394) take the
14
14
  * one-second `EVENT_WAIT_POLL_MS` budget when the adapter passes it per call
15
15
  * (see `EVENT_WAIT_POLL_MS` for which adapters do). Closed streams never wait.
16
16
  *
@@ -28,15 +28,14 @@ export const STREAM_WAIT_POLL_MS = 25
28
28
 
29
29
  /**
30
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 —
31
+ * AUT-334/381/383/394): held event reads — build, repository, and session —
32
32
  * re-query at most once per second, so an append is observed at the next
33
33
  * poll, typically within about one second. Held *stream* reads are excluded:
34
34
  * they are presentation content and stay at the ~25 ms `STREAM_WAIT_POLL_MS`
35
35
  * cadence. This deliberately diverges from `STREAM_WAIT_POLL_MS`; adapters
36
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.
37
+ * the PostgreSQL, memory, and SQLite stores do on all three families
38
+ * (AUT-334/381/383/394).
40
39
  *
41
40
  * The PostgreSQL adapter treats the bound as nominal, not hard: a worst-case
42
41
  * ≤1 s guarantee would need Postgres LISTEN/NOTIFY wake-on-append — one
@@ -0,0 +1,458 @@
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 { defineReducer } from '../kernel/incremental'
14
+ import { contentHash, type BlobStore, type BuildStore } from './types'
15
+
16
+ export type { PinnedAsset }
17
+
18
+ /** Total file bytes per revision (25 MiB). */
19
+ export const TICKET_ASSET_MAX_BYTES = 25 * 1024 * 1024
20
+ /** Files plus directories per revision. */
21
+ export const TICKET_ASSET_MAX_ENTRIES = 1000
22
+ /** UTF-8 bytes per entry path. */
23
+ export const TICKET_ASSET_MAX_PATH_BYTES = 256
24
+ /** UTF-8 bytes per path segment. */
25
+ export const TICKET_ASSET_MAX_SEGMENT_BYTES = 128
26
+ /** Kind and name length. */
27
+ export const TICKET_ASSET_MAX_LABEL_LENGTH = 64
28
+
29
+ /** Worst-case JSON envelope around the entries of one put request. */
30
+ export const WIRE_ENVELOPE_BYTES = 1024
31
+ /** Worst-case JSON per entry besides base64 content: a maximum-length path
32
+ * plus keys, quotes, commas, the type tag, and base64 padding. */
33
+ export const WIRE_ENTRY_BYTES = TICKET_ASSET_MAX_PATH_BYTES + 64
34
+
35
+ export type TicketAssetLayout = 'file' | 'tree'
36
+
37
+ /** An entry is a file or an explicit directory. Paths are relative and
38
+ * '/'-separated (`index.html`, `img/a.png`, `img/empty`). */
39
+ export type TicketAssetEntryInput =
40
+ | { type: 'file'; path: string; content: Uint8Array }
41
+ | { type: 'dir'; path: string }
42
+
43
+ export interface TicketAssetInput {
44
+ kind: string
45
+ name: string
46
+ layout: TicketAssetLayout
47
+ entries: TicketAssetEntryInput[]
48
+ }
49
+
50
+ export type TicketAssetEntry =
51
+ | { type: 'file'; path: string; size: number; blobRef: string }
52
+ | { type: 'dir'; path: string }
53
+
54
+ export interface TicketAssetMeta {
55
+ repo: string
56
+ ticketId: string
57
+ kind: string
58
+ name: string
59
+ /** 0-based per `(repo, ticketId, kind, name)`. A removal consumes a number
60
+ * too (a tombstone), so revisions may skip. */
61
+ revision: number
62
+ layout: TicketAssetLayout
63
+ /** Total file bytes. */
64
+ size: number
65
+ entries: TicketAssetEntry[]
66
+ createdAt: string
67
+ }
68
+
69
+ /** What `listTicketAssets` returns on every adapter: the metadata without
70
+ * the manifest. */
71
+ export interface TicketAssetSummary {
72
+ repo: string
73
+ ticketId: string
74
+ kind: string
75
+ name: string
76
+ revision: number
77
+ layout: TicketAssetLayout
78
+ size: number
79
+ fileCount: number
80
+ dirCount: number
81
+ createdAt: string
82
+ }
83
+
84
+ export type TicketAssetContentEntry =
85
+ | { type: 'file'; path: string; content: Uint8Array }
86
+ | { type: 'dir'; path: string }
87
+
88
+ export interface TicketAsset {
89
+ meta: TicketAssetMeta
90
+ entries: TicketAssetContentEntry[]
91
+ }
92
+
93
+ export interface TicketAssetLimits {
94
+ maxBytes: number
95
+ maxEntries: number
96
+ }
97
+
98
+ export const DEFAULT_TICKET_ASSET_LIMITS: TicketAssetLimits = {
99
+ maxBytes: TICKET_ASSET_MAX_BYTES,
100
+ maxEntries: TICKET_ASSET_MAX_ENTRIES,
101
+ }
102
+
103
+ export class TicketAssetValidationError extends Error {
104
+ constructor(message: string) {
105
+ super(message)
106
+ this.name = 'TicketAssetValidationError'
107
+ }
108
+ }
109
+
110
+ export function summarizeTicketAsset(meta: TicketAssetMeta): TicketAssetSummary {
111
+ let fileCount = 0
112
+ let dirCount = 0
113
+ for (const entry of meta.entries) {
114
+ if (entry.type === 'file') fileCount += 1
115
+ else dirCount += 1
116
+ }
117
+ return {
118
+ repo: meta.repo,
119
+ ticketId: meta.ticketId,
120
+ kind: meta.kind,
121
+ name: meta.name,
122
+ revision: meta.revision,
123
+ layout: meta.layout,
124
+ size: meta.size,
125
+ fileCount,
126
+ dirCount,
127
+ createdAt: meta.createdAt,
128
+ }
129
+ }
130
+
131
+ function formatBytes(bytes: number): string {
132
+ const mib = bytes / (1024 * 1024)
133
+ if (bytes >= 1024 * 1024 && Number.isInteger(mib)) return `${mib} MiB`
134
+ if (bytes >= 1024 * 1024) return `${mib.toFixed(1)} MiB`
135
+ if (bytes >= 1024 && bytes % 1024 === 0) return `${bytes / 1024} KiB`
136
+ return `${bytes} bytes`
137
+ }
138
+
139
+ /** The limit-naming error for an over-size revision. `size` is the total file
140
+ * bytes the caller tried to store. */
141
+ export function ticketAssetSizeError(size: number, limit: number): TicketAssetValidationError {
142
+ return new TicketAssetValidationError(
143
+ `ticket asset exceeds the ${limit}-byte (${formatBytes(limit)}) limit: ${size} bytes`,
144
+ )
145
+ }
146
+
147
+ export function ticketAssetEntriesError(count: number, limit: number): TicketAssetValidationError {
148
+ return new TicketAssetValidationError(
149
+ `ticket asset exceeds the ${limit}-entry limit: ${count} entries (files plus directories)`,
150
+ )
151
+ }
152
+
153
+ /** The effective limits of a store whose requests and responses can carry at
154
+ * most `maxRequestBytes`. Paths and entry counts are bounded, so the worst-case
155
+ * wire overhead is a constant; base64 inflates content by 4/3. A safe lower
156
+ * bound: the client's exact-size check is the final authority. */
157
+ export function effectiveTicketAssetLimits(maxRequestBytes?: number): TicketAssetLimits {
158
+ if (maxRequestBytes === undefined) return { ...DEFAULT_TICKET_ASSET_LIMITS }
159
+ const overhead = WIRE_ENVELOPE_BYTES + TICKET_ASSET_MAX_ENTRIES * WIRE_ENTRY_BYTES
160
+ return {
161
+ maxBytes: Math.min(
162
+ TICKET_ASSET_MAX_BYTES,
163
+ Math.max(0, Math.floor(((maxRequestBytes - overhead) * 3) / 4)),
164
+ ),
165
+ maxEntries: TICKET_ASSET_MAX_ENTRIES,
166
+ }
167
+ }
168
+
169
+ const LABEL_PATTERN = /^[A-Za-z0-9._-]+$/
170
+
171
+ export function validateTicketAssetLabel(field: 'kind' | 'name', value: string): void {
172
+ if (typeof value !== 'string' || value.trim() === '') {
173
+ throw new TicketAssetValidationError(`ticket asset ${field} is required`)
174
+ }
175
+ if (value.length > TICKET_ASSET_MAX_LABEL_LENGTH) {
176
+ throw new TicketAssetValidationError(
177
+ `ticket asset ${field} must be at most ${TICKET_ASSET_MAX_LABEL_LENGTH} characters`,
178
+ )
179
+ }
180
+ if (!LABEL_PATTERN.test(value) || value === '.' || value === '..') {
181
+ throw new TicketAssetValidationError(
182
+ `ticket asset ${field} "${value}" may contain only letters, digits, '.', '_' and '-'`,
183
+ )
184
+ }
185
+ }
186
+
187
+ const encoder = new TextEncoder()
188
+
189
+ /** One entry path: relative, '/'-separated, no empty/`.`/`..` segments, no
190
+ * NUL, bounded. */
191
+ export function validateTicketAssetPath(path: string): void {
192
+ if (typeof path !== 'string' || path === '') {
193
+ throw new TicketAssetValidationError('ticket asset entry path is required')
194
+ }
195
+ if (path.includes('\0')) {
196
+ throw new TicketAssetValidationError(`ticket asset entry path "${path}" contains a NUL byte`)
197
+ }
198
+ if (path.includes('\\')) {
199
+ throw new TicketAssetValidationError(
200
+ `ticket asset entry path "${path}" must use '/' separators`,
201
+ )
202
+ }
203
+ if (encoder.encode(path).length > TICKET_ASSET_MAX_PATH_BYTES) {
204
+ throw new TicketAssetValidationError(
205
+ `ticket asset entry path exceeds ${TICKET_ASSET_MAX_PATH_BYTES} bytes: "${path.slice(0, 40)}…"`,
206
+ )
207
+ }
208
+ if (path.startsWith('/')) {
209
+ throw new TicketAssetValidationError(`ticket asset entry path "${path}" must be relative`)
210
+ }
211
+ for (const segment of path.split('/')) {
212
+ if (segment === '' || segment === '.' || segment === '..') {
213
+ throw new TicketAssetValidationError(
214
+ `ticket asset entry path "${path}" has an empty, '.' or '..' segment`,
215
+ )
216
+ }
217
+ if (encoder.encode(segment).length > TICKET_ASSET_MAX_SEGMENT_BYTES) {
218
+ throw new TicketAssetValidationError(
219
+ `ticket asset entry path segment exceeds ${TICKET_ASSET_MAX_SEGMENT_BYTES} bytes in "${path.slice(0, 40)}…"`,
220
+ )
221
+ }
222
+ }
223
+ }
224
+
225
+ /** Shape and structure of a manifest, independent of content: shared by put
226
+ * validation and the remote server's check of an incoming request. Returns
227
+ * the total file bytes. */
228
+ export function validateTicketAssetStructure(
229
+ input: {
230
+ kind: string
231
+ name: string
232
+ layout: TicketAssetLayout
233
+ entries: ({ type: 'file'; path: string; size: number } | { type: 'dir'; path: string })[]
234
+ },
235
+ limits: TicketAssetLimits = DEFAULT_TICKET_ASSET_LIMITS,
236
+ ): number {
237
+ validateTicketAssetLabel('kind', input.kind)
238
+ validateTicketAssetLabel('name', input.name)
239
+ if (input.layout !== 'file' && input.layout !== 'tree') {
240
+ throw new TicketAssetValidationError(`ticket asset layout must be 'file' or 'tree'`)
241
+ }
242
+ const files = input.entries.filter((entry) => entry.type === 'file')
243
+ if (input.layout === 'file') {
244
+ if (input.entries.length !== 1 || files.length !== 1) {
245
+ throw new TicketAssetValidationError(
246
+ `a 'file' ticket asset has exactly one file entry and no directories`,
247
+ )
248
+ }
249
+ if (files[0]?.path.includes('/')) {
250
+ throw new TicketAssetValidationError(`a 'file' ticket asset's path is its filename`)
251
+ }
252
+ }
253
+ if (input.entries.length > limits.maxEntries) {
254
+ throw ticketAssetEntriesError(input.entries.length, limits.maxEntries)
255
+ }
256
+ const kinds = new Map<string, 'file' | 'dir'>()
257
+ let size = 0
258
+ for (const entry of input.entries) {
259
+ if (entry.type !== 'file' && entry.type !== 'dir') {
260
+ throw new TicketAssetValidationError(`ticket asset entry type must be 'file' or 'dir'`)
261
+ }
262
+ validateTicketAssetPath(entry.path)
263
+ if (kinds.has(entry.path)) {
264
+ throw new TicketAssetValidationError(`duplicate ticket asset entry path "${entry.path}"`)
265
+ }
266
+ kinds.set(entry.path, entry.type)
267
+ if (entry.type === 'file') size += entry.size
268
+ }
269
+ for (const [path] of kinds) {
270
+ const segments = path.split('/')
271
+ for (let end = 1; end < segments.length; end += 1) {
272
+ const ancestor = segments.slice(0, end).join('/')
273
+ if (kinds.get(ancestor) === 'file') {
274
+ throw new TicketAssetValidationError(
275
+ `ticket asset entry "${path}" has a file as its ancestor directory "${ancestor}"`,
276
+ )
277
+ }
278
+ }
279
+ }
280
+ if (size > limits.maxBytes) throw ticketAssetSizeError(size, limits.maxBytes)
281
+ return size
282
+ }
283
+
284
+ /** The one validator every adapter calls before any write. Throws
285
+ * `TicketAssetValidationError`; returns the total file bytes. */
286
+ export function validateTicketAssetInput(
287
+ input: TicketAssetInput,
288
+ limits: TicketAssetLimits = DEFAULT_TICKET_ASSET_LIMITS,
289
+ ): number {
290
+ return validateTicketAssetStructure(
291
+ {
292
+ kind: input.kind,
293
+ name: input.name,
294
+ layout: input.layout,
295
+ entries: input.entries.map((entry) =>
296
+ entry.type === 'file'
297
+ ? { type: 'file', path: entry.path, size: entry.content.byteLength }
298
+ : { type: 'dir', path: entry.path },
299
+ ),
300
+ },
301
+ limits,
302
+ )
303
+ }
304
+
305
+ /** Write each file's bytes to the blob store (content-addressed) and build the
306
+ * manifest. Call only after `validateTicketAssetInput`. */
307
+ export async function storeTicketAssetBlobs(
308
+ blobs: BlobStore,
309
+ entries: TicketAssetEntryInput[],
310
+ ): Promise<TicketAssetEntry[]> {
311
+ const manifest: TicketAssetEntry[] = []
312
+ for (const entry of entries) {
313
+ if (entry.type === 'dir') {
314
+ manifest.push({ type: 'dir', path: entry.path })
315
+ continue
316
+ }
317
+ const blobRef = contentHash(entry.content)
318
+ await blobs.put(blobRef, entry.content)
319
+ manifest.push({ type: 'file', path: entry.path, size: entry.content.byteLength, blobRef })
320
+ }
321
+ return manifest
322
+ }
323
+
324
+ /** Read the bytes of one manifest file; a missing blob is store corruption. */
325
+ export async function readTicketAssetBlob(
326
+ blobs: BlobStore,
327
+ meta: TicketAssetMeta,
328
+ entry: { path: string; blobRef: string },
329
+ ): Promise<Uint8Array> {
330
+ const bytes = await blobs.get(entry.blobRef)
331
+ if (!bytes) {
332
+ throw new Error(
333
+ `ticket asset ${meta.kind}/${meta.name} revision ${meta.revision}: blob for "${entry.path}" is missing`,
334
+ )
335
+ }
336
+ return bytes
337
+ }
338
+
339
+ /** Load a manifest's full content (every adapter's `getTicketAsset` tail). */
340
+ export async function loadTicketAsset(
341
+ blobs: BlobStore,
342
+ meta: TicketAssetMeta,
343
+ ): Promise<TicketAsset> {
344
+ const entries: TicketAssetContentEntry[] = []
345
+ for (const entry of meta.entries) {
346
+ entries.push(
347
+ entry.type === 'dir'
348
+ ? { type: 'dir', path: entry.path }
349
+ : {
350
+ type: 'file',
351
+ path: entry.path,
352
+ content: await readTicketAssetBlob(blobs, meta, entry),
353
+ },
354
+ )
355
+ }
356
+ return { meta: structuredClone(meta), entries }
357
+ }
358
+
359
+ /** What a build pins of one asset revision: the summary minus the ticket
360
+ * coordinates and timestamp, which the build record already carries. */
361
+ export function pinnedAssetOf(summary: TicketAssetSummary): PinnedAsset {
362
+ return {
363
+ kind: summary.kind,
364
+ name: summary.name,
365
+ revision: summary.revision,
366
+ layout: summary.layout,
367
+ size: summary.size,
368
+ fileCount: summary.fileCount,
369
+ dirCount: summary.dirCount,
370
+ }
371
+ }
372
+
373
+ /** The set of ticket assets to freeze for a claim or a spec re-pin: every
374
+ * current (live) asset of the ticket, in `listTicketAssets` order. */
375
+ export async function samplePinnedAssets(
376
+ store: Pick<BuildStore, 'listTicketAssets'>,
377
+ repo: string,
378
+ ticketId: string,
379
+ ): Promise<PinnedAsset[]> {
380
+ return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
381
+ }
382
+
383
+ export const PINNED_ASSETS_REDUCER_VERSION = 1
384
+
385
+ /** The asset sets recorded by every `build.created`/`spec.revised` pin event
386
+ * that carries one, in array order. Each pin event replaces the set, so the
387
+ * newest decides the live pin; all of them answer "was this revision ever
388
+ * named". `pinnedRevision` is the query over the finished state. */
389
+ export const pinnedAssetsReducer = defineReducer<
390
+ { pins: PinnedAsset[][] },
391
+ AbEvent,
392
+ { pins: PinnedAsset[][] }
393
+ >({
394
+ version: PINNED_ASSETS_REDUCER_VERSION,
395
+ initial: () => ({ pins: [] }),
396
+ fold(acc, events) {
397
+ for (const event of events) {
398
+ if (event.type !== 'build.created' && event.type !== 'spec.revised') continue
399
+ const assets = event.payload.assets
400
+ if (assets === undefined) continue
401
+ acc.pins.push(assets)
402
+ }
403
+ },
404
+ finish: (acc) => ({ pins: acc.pins.slice() }),
405
+ })
406
+
407
+ /** Latest-pinned revision of `kind/name` over a reduced pin state, or (with
408
+ * `rev`) the revision when any pin named it. */
409
+ export function pinnedRevision(
410
+ state: { pins: readonly (readonly PinnedAsset[])[] },
411
+ kind: string,
412
+ name: string,
413
+ rev?: number,
414
+ ): number | undefined {
415
+ let latest: number | undefined
416
+ for (const assets of state.pins) {
417
+ if (rev !== undefined) {
418
+ if (assets.some((a) => a.kind === kind && a.name === name && a.revision === rev)) return rev
419
+ continue
420
+ }
421
+ // Each pin event replaces the set, so the newest event that carries a set
422
+ // decides; absent from that set means unpinned.
423
+ latest = assets.find((a) => a.kind === kind && a.name === name)?.revision
424
+ }
425
+ return latest
426
+ }
427
+
428
+ /** Latest-pinned revision of `kind/name` as the build's events record it, or
429
+ * (with `rev`) the revision when any pin in the log named it. */
430
+ export function findPinnedRevision(
431
+ events: AbEvent[],
432
+ kind: string,
433
+ name: string,
434
+ rev?: number,
435
+ ): number | undefined {
436
+ return pinnedRevision(pinnedAssetsReducer.reduce(events), kind, name, rev)
437
+ }
438
+
439
+ /** `BuildStore.getPinnedTicketAsset` for any adapter: read the build's own
440
+ * events to find the pinned revision, then fetch exactly that revision from the
441
+ * ticket's assets. Earlier revisions stay retrievable after a replace or a
442
+ * removal (SPEC §7.1), so the bytes can never drift, and no lease or liveness is
443
+ * needed, so a finished build still answers. Null when the build never pinned
444
+ * `kind/name` (or the revision). Rejects an unknown build. */
445
+ export async function resolvePinnedAsset(
446
+ store: Pick<BuildStore, 'getBuild' | 'getEvents' | 'getTicketAsset'>,
447
+ slug: string,
448
+ kind: string,
449
+ name: string,
450
+ rev?: number,
451
+ ): Promise<TicketAsset | null> {
452
+ const record = await store.getBuild(slug)
453
+ if (record === null) throw new Error(`unknown build "${slug}"`)
454
+ if (record.ticket === undefined) return null
455
+ const revision = findPinnedRevision(await store.getEvents(slug), kind, name, rev)
456
+ if (revision === undefined) return null
457
+ return store.getTicketAsset(record.repo, record.ticket.id, kind, name, revision)
458
+ }