@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
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Pipeline-source resolution (SPEC §16.1): where a build's build-owned config
3
+ * sections come from. A build's pipeline is pinned to its own branch — the
4
+ * branch head once the build has published commits there, otherwise the
5
+ * recorded base commit its workspace was cut from. The dispatcher resolves
6
+ * this once per deposit (launch and reload), records the exact commit in the
7
+ * effective-config artifact's metadata, and never substitutes a later
8
+ * base-branch change (the AUT-366 class of failure).
9
+ */
10
+ import { readFile } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import type { AbEvent } from '../events/catalog'
13
+ import { defineReducer } from '../kernel/incremental'
14
+ import type { Forge } from '../ports/types'
15
+ import type { Exec } from '../ports/workspace/git-worktree'
16
+ import type { BuildRecord } from '../store/types'
17
+ import { parseConfig } from './load'
18
+ import type { Config } from './schema'
19
+
20
+ /** Which fact the pinned pipeline was read from. `legacy-fallback` marks the
21
+ * no-migration path: no recorded base (or an unreadable one), so the deposit
22
+ * is the dispatcher's live snapshot exactly as before this change.
23
+ * `worktree-dirty` marks the checkout-mode worktree read whose git-normalized
24
+ * content does not match the resolved branch head (uncommitted edits, or a
25
+ * file never committed): no commit is recorded, because attributing worktree
26
+ * content to the branch-head commit would be false provenance. */
27
+ export type PipelineSourceRef = 'branch-head' | 'base' | 'legacy-fallback' | 'worktree-dirty'
28
+
29
+ const PIPELINE_SOURCE_REFS: readonly PipelineSourceRef[] = [
30
+ 'branch-head',
31
+ 'base',
32
+ 'legacy-fallback',
33
+ 'worktree-dirty',
34
+ ]
35
+
36
+ export function isPipelineSourceRef(value: unknown): value is PipelineSourceRef {
37
+ return typeof value === 'string' && (PIPELINE_SOURCE_REFS as readonly string[]).includes(value)
38
+ }
39
+
40
+ /** Metadata recorded alongside every deposited effective-config artifact. */
41
+ export interface PipelineSourceMeta {
42
+ ref: PipelineSourceRef
43
+ /** Exact commit the pipeline sections were parsed from. Absent for the
44
+ * legacy fallback (no build-branch/base read was possible) and for
45
+ * `worktree-dirty` (worktree bytes that no commit claims). */
46
+ commit?: string
47
+ }
48
+
49
+ export interface PipelineSource {
50
+ meta: PipelineSourceMeta
51
+ /** The parsed build-owned tables. */
52
+ config: Config
53
+ }
54
+
55
+ export interface PipelineSourceInput {
56
+ slug: string
57
+ record: Pick<BuildRecord, 'branch'>
58
+ events: readonly AbEvent[]
59
+ /** 'origin' reads through the forge; 'checkout' reads through git exec in
60
+ * the dispatcher's checkout (build branches are refs of the shared repo). */
61
+ mode: 'origin' | 'checkout'
62
+ /** Origin-mode read capability. Required to read through the forge; absent
63
+ * degrades to the recorded base / legacy fallback. */
64
+ forge?: Forge
65
+ /** Checkout-mode seam: the physical main checkout and exec. */
66
+ checkout?: string
67
+ exec?: Exec
68
+ /** Checkout-mode seam: the open build worktree, whose `autobuild.toml` is
69
+ * the branch head. Preferred over `git show` when the file exists. */
70
+ workspacePath?: string
71
+ /** Optional degradation sink: one failure per read/parse attempt that forced
72
+ * a fallback, in order. Callers surface the actionable ones as per-build
73
+ * diagnostics (SPEC §16.1) instead of the resolver throwing. */
74
+ onFailure?: (failure: PipelineSourceFailure) => void
75
+ }
76
+
77
+ /** Why a resolution degraded. `unpublished`, `base-source` and `no-base` are
78
+ * the documented fallback order (a build whose branch is not published yet, a
79
+ * legacy build with no readable recorded base) — normal operation, quiet.
80
+ * `capability` and `branch-source` are failures an operator can act on: a
81
+ * wiring gap, or a build-branch source that resolved but cannot be read or
82
+ * parsed. */
83
+ export type PipelineSourceFailureKind =
84
+ | 'capability'
85
+ | 'unpublished'
86
+ | 'branch-source'
87
+ | 'base-source'
88
+ | 'no-base'
89
+ | 'store'
90
+
91
+ export interface PipelineSourceFailure {
92
+ kind: PipelineSourceFailureKind
93
+ detail: string
94
+ }
95
+
96
+ /** The failure kinds worth an operator-facing diagnostic: a wiring gap, a
97
+ * build-branch source that resolved but cannot be read or parsed, or a store
98
+ * failure — each something an operator (or the build's author) can fix. */
99
+ export function isActionablePipelineFailure(failure: PipelineSourceFailure): boolean {
100
+ return (
101
+ failure.kind === 'capability' || failure.kind === 'branch-source' || failure.kind === 'store'
102
+ )
103
+ }
104
+
105
+ export const RECORDED_BASE_SHA_REDUCER_VERSION = 1
106
+
107
+ /** The recorded base commit of the build's latest workspace. The newest
108
+ * `workspace.provisioned` wins: a re-provisioned workspace is what the build's
109
+ * current environment was cut from. */
110
+ export const recordedBaseShaReducer = defineReducer<{ base?: string }, AbEvent, string | undefined>(
111
+ {
112
+ version: RECORDED_BASE_SHA_REDUCER_VERSION,
113
+ initial: () => ({}),
114
+ fold(acc, events) {
115
+ for (const event of events) {
116
+ if (event.type === 'workspace.provisioned') acc.base = event.payload.base.sha
117
+ }
118
+ },
119
+ finish: (acc) => acc.base,
120
+ },
121
+ )
122
+
123
+ export function recordedBaseSha(events: readonly AbEvent[]): string | undefined {
124
+ return recordedBaseShaReducer.reduce(events)
125
+ }
126
+
127
+ function buildBranch(record: Pick<BuildRecord, 'branch'>, slug: string): string {
128
+ return record.branch ?? `ab/${slug}`
129
+ }
130
+
131
+ function git(
132
+ args: string[],
133
+ cwd: string,
134
+ exec: Exec,
135
+ ): Promise<{ stdout: string; exitCode: number; stderr: string }> {
136
+ return exec(['git', ...args], { cwd })
137
+ }
138
+
139
+ async function readCheckoutFile(
140
+ ref: string,
141
+ path: string,
142
+ checkout: string,
143
+ exec: Exec,
144
+ ): Promise<string> {
145
+ const result = await git(['show', `${ref}:${path}`], checkout, exec)
146
+ if (result.exitCode !== 0) {
147
+ throw new Error(`git show ${ref}:${path} failed: ${result.stderr.trim() || '(no output)'}`)
148
+ }
149
+ return result.stdout
150
+ }
151
+
152
+ function message(error: unknown): string {
153
+ return error instanceof Error ? error.message : String(error)
154
+ }
155
+
156
+ /** Parse a read file into a pipeline source, or `undefined` when the TOML is
157
+ * malformed. A branch-source parse failure is terminal: the build's own branch
158
+ * exists but carries an unparseable pipeline, so falling back to the recorded
159
+ * base would run the build under a pipeline that is not its own — the AUT-366
160
+ * poisoning by another door. The caller keeps the build's last good deposit
161
+ * instead (SPEC §16.1). */
162
+ function parsePipeline(
163
+ commit: string | undefined,
164
+ ref: PipelineSourceRef,
165
+ content: string,
166
+ onFailure: (failure: PipelineSourceFailure) => void,
167
+ ): PipelineSource | undefined {
168
+ const label = commit === undefined ? ref : `${ref}@${commit}`
169
+ try {
170
+ return {
171
+ meta: commit === undefined ? { ref } : { ref, commit },
172
+ config: parseConfig(content, `autobuild.toml@${label}`),
173
+ }
174
+ } catch (error) {
175
+ onFailure({
176
+ // `worktree-dirty` is a branch-source failure: the malformed file sits on
177
+ // the build's own side (its open worktree), so a base fallback would run
178
+ // the build under a pipeline that is not its own — same as `branch-head`.
179
+ kind: ref === 'base' ? 'base-source' : 'branch-source',
180
+ detail: `malformed autobuild.toml at ${label}: ${message(error)}`,
181
+ })
182
+ return undefined
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Resolve the pipeline source for one build, or `undefined` for the legacy
188
+ * fallback. Every failure — missing capability, missing base record, unread
189
+ * file, malformed TOML — degrades to the fallback instead of failing the
190
+ * caller: a config deposit is never allowed to fail a dispatcher tick or a
191
+ * launch.
192
+ *
193
+ * The resolution rule (SPEC §16.1): the build's branch head once it resolves
194
+ * (origin mode: the forge's remote branch; checkout mode: the shared repo's
195
+ * branch ref), else the workspace's recorded base commit. The caller re-
196
+ * resolves at every launch, so a build that publishes pipeline changes to its
197
+ * own branch picks them up at its next launch.
198
+ */
199
+ export async function resolvePipelineSource(
200
+ input: PipelineSourceInput,
201
+ ): Promise<PipelineSource | undefined> {
202
+ const { slug, record, events } = input
203
+ const onFailure = input.onFailure ?? (() => {})
204
+ const branch = buildBranch(record, slug)
205
+ const path = 'autobuild.toml'
206
+
207
+ if (input.mode === 'origin') {
208
+ const forge = input.forge
209
+ if (forge === undefined) {
210
+ onFailure({ kind: 'capability', detail: 'origin mode with no forge' })
211
+ return undefined
212
+ }
213
+ const { remoteBranchSha, readFile: forgeRead } = forge
214
+ if (remoteBranchSha === undefined || forgeRead === undefined) {
215
+ onFailure({
216
+ kind: 'capability',
217
+ detail:
218
+ `forge lacks the ${remoteBranchSha === undefined ? 'remoteBranchSha' : 'readFile'} ` +
219
+ 'capability the pipeline-source read needs',
220
+ })
221
+ } else {
222
+ let head: string | undefined
223
+ try {
224
+ head = await remoteBranchSha.call(forge, branch)
225
+ } catch (error) {
226
+ // Branch not yet published (or the forge failed): fall through to the
227
+ // recorded base commit, which is exactly what the workspace was cut
228
+ // from. Surfaced as `unpublished` either way — the normal order.
229
+ onFailure({
230
+ kind: 'unpublished',
231
+ detail: `forge remoteBranchSha(${branch}) failed: ${message(error)}`,
232
+ })
233
+ }
234
+ if (head !== undefined) {
235
+ let content: string | undefined
236
+ try {
237
+ content = await forgeRead.call(forge, path, head)
238
+ } catch (error) {
239
+ // The branch resolved but its file did not come back: an actionable
240
+ // forge failure, not an unpublished branch.
241
+ onFailure({
242
+ kind: 'branch-source',
243
+ detail: `forge readFile(${path}, ${head}) failed: ${message(error)}`,
244
+ })
245
+ }
246
+ if (content !== undefined) {
247
+ // A malformed build-branch file is terminal: the build's own branch
248
+ // exists, so the recorded base is not its pipeline either.
249
+ return parsePipeline(head, 'branch-head', content, onFailure)
250
+ }
251
+ }
252
+ }
253
+ const baseSha = recordedBaseSha(events)
254
+ if (baseSha === undefined) {
255
+ onFailure({ kind: 'no-base', detail: 'no recorded base commit (legacy build)' })
256
+ return undefined
257
+ }
258
+ if (forgeRead === undefined) return undefined
259
+ let baseContent: string | undefined
260
+ try {
261
+ baseContent = await forgeRead.call(forge, path, baseSha)
262
+ } catch (error) {
263
+ // Base file unreadable (legacy logs, deleted history): legacy fallback.
264
+ onFailure({
265
+ kind: 'base-source',
266
+ detail: `forge readFile(${path}, base ${baseSha}) failed: ${message(error)}`,
267
+ })
268
+ return undefined
269
+ }
270
+ return parsePipeline(baseSha, 'base', baseContent, onFailure)
271
+ }
272
+
273
+ const { checkout, exec, workspacePath } = input
274
+ if (checkout === undefined || exec === undefined) {
275
+ onFailure({ kind: 'capability', detail: 'checkout mode with no checkout or exec seam' })
276
+ return undefined
277
+ }
278
+ // The build branch is a ref of the shared repository: it exists from
279
+ // provision time (cut from base) and survives workspace release, so the
280
+ // committed head is readable at every launch boundary — including the
281
+ // relaunch after finalize. The recorded commit is the exact sha read.
282
+ let head: string | undefined
283
+ try {
284
+ const resolved = await git(['rev-parse', '--verify', `refs/heads/${branch}`], checkout, exec)
285
+ if (resolved.exitCode === 0) {
286
+ head = resolved.stdout.trim()
287
+ } else {
288
+ // Unpublished branch: the normal order, resolved from the base next.
289
+ onFailure({
290
+ kind: 'unpublished',
291
+ detail: `git rev-parse refs/heads/${branch} failed: ${
292
+ resolved.stderr.trim() || '(no output)'
293
+ }`,
294
+ })
295
+ }
296
+ } catch (error) {
297
+ // Fall through to the recorded base commit.
298
+ onFailure({
299
+ kind: 'unpublished',
300
+ detail: `git rev-parse refs/heads/${branch} failed: ${message(error)}`,
301
+ })
302
+ }
303
+ if (head !== undefined) {
304
+ // Checkout mode's primary read is the open build worktree's file — it is
305
+ // the branch head. The committed ref read is the released-workspace
306
+ // fallback, and the recorded commit stays the resolved branch head.
307
+ if (workspacePath !== undefined) {
308
+ try {
309
+ const content = await readFile(join(workspacePath, path), 'utf8')
310
+ // The worktree file may carry uncommitted edits, so it is only the
311
+ // branch head's when git sees no difference between them. Compare
312
+ // git-normalized identity, not raw bytes: the stored blob can differ
313
+ // textually from the worktree file (core.autocrlf, eol attributes)
314
+ // while carrying the same content, so byte equality against `git show`
315
+ // output would misclassify a clean worktree as dirty. Hash the
316
+ // worktree file the way git would store it — `git hash-object` run in
317
+ // the workspace applies the same clean conversion as check-in — and
318
+ // compare against the committed blob's oid. Equal → attribute to the
319
+ // head commit as before; different bytes, a file the head never
320
+ // carried, or any failed comparison (a non-git workspace, a transient
321
+ // git error) → `worktree-dirty` with no commit: the provenance
322
+ // declines to attribute worktree bytes to any commit. A comparison
323
+ // failure is caught here, not by the committed-ref fallback below —
324
+ // it degrades to dirty, never to a different source.
325
+ let clean = false
326
+ try {
327
+ const [committedBlob, worktreeBlob] = await Promise.all([
328
+ git(['rev-parse', '--verify', `${head}:${path}`], checkout, exec),
329
+ exec(['git', 'hash-object', '--', path], { cwd: workspacePath }),
330
+ ])
331
+ clean =
332
+ committedBlob.exitCode === 0 &&
333
+ worktreeBlob.exitCode === 0 &&
334
+ committedBlob.stdout.trim() === worktreeBlob.stdout.trim()
335
+ } catch {
336
+ clean = false
337
+ }
338
+ if (clean) {
339
+ // A malformed worktree file is terminal: the committed ref carries the
340
+ // same content, so the recorded base is not the build's pipeline
341
+ // either. The caller keeps the build's last good deposit.
342
+ return parsePipeline(head, 'branch-head', content, onFailure)
343
+ }
344
+ return parsePipeline(undefined, 'worktree-dirty', content, onFailure)
345
+ } catch (error) {
346
+ // Workspace released or unreadable: read the committed branch head.
347
+ onFailure({
348
+ kind: 'unpublished',
349
+ detail: `workspace file ${join(workspacePath, path)} unreadable: ${message(error)}`,
350
+ })
351
+ }
352
+ }
353
+ let content: string | undefined
354
+ try {
355
+ content = await readCheckoutFile(head, path, checkout, exec)
356
+ } catch (error) {
357
+ // The ref resolved but its file did not come back: corruption or an
358
+ // exec failure — actionable, unlike an unpublished branch.
359
+ onFailure({
360
+ kind: 'branch-source',
361
+ detail: `git show ${head}:${path} failed: ${message(error)}`,
362
+ })
363
+ }
364
+ if (content !== undefined) {
365
+ // Malformed committed branch file: terminal, as above.
366
+ const parsed = parsePipeline(head, 'branch-head', content, onFailure)
367
+ if (parsed === undefined) return undefined
368
+ return parsed
369
+ }
370
+ }
371
+ const baseSha = recordedBaseSha(events)
372
+ if (baseSha === undefined) {
373
+ onFailure({ kind: 'no-base', detail: 'no recorded base commit (legacy build)' })
374
+ return undefined
375
+ }
376
+ let baseContent: string | undefined
377
+ try {
378
+ baseContent = await readCheckoutFile(baseSha, path, checkout, exec)
379
+ } catch (error) {
380
+ // Base file unreadable: legacy fallback.
381
+ onFailure({
382
+ kind: 'base-source',
383
+ detail: `git show ${baseSha}:${path} failed: ${message(error)}`,
384
+ })
385
+ return undefined
386
+ }
387
+ return parsePipeline(baseSha, 'base', baseContent, onFailure)
388
+ }