@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- 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 (
|
|
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.
|