@defrex/autobuild 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,138 @@
1
+ import type { AbEvent, EventEnvelope } from '../events/catalog'
2
+ import { KERNEL } from '../events/envelope'
3
+ import type { IdSource } from '../ids'
4
+ import {
5
+ currentPrAttachments,
6
+ frozenPrImageHost,
7
+ hostedPrAttachments,
8
+ } from '../kernel/pr-attachments'
9
+ import { hostedPrAttachmentAssetSchema, type HostedPrAttachmentAsset } from '../ontology'
10
+ import type { Forge } from '../ports/types'
11
+ import type { BuildStore } from '../store/types'
12
+ import type { CliEnv } from './env'
13
+
14
+ export interface PrAttachmentDeps {
15
+ store: BuildStore
16
+ env: CliEnv
17
+ workspacePath: string
18
+ forge: Forge
19
+ ids: IdSource
20
+ }
21
+
22
+ function errorMessage(error: unknown): string {
23
+ return error instanceof Error ? error.message : String(error)
24
+ }
25
+
26
+ async function recordHostingFailure(
27
+ deps: PrAttachmentDeps,
28
+ events: AbEvent[],
29
+ designation: ReturnType<typeof currentPrAttachments>[number],
30
+ error: unknown,
31
+ extraRefs: string[] = [],
32
+ ): Promise<void> {
33
+ const { artifact } = designation.payload
34
+ try {
35
+ const observed = await deps.store.append(deps.env.build, {
36
+ actor: KERNEL,
37
+ type: 'observation.recorded',
38
+ payload: {
39
+ id: deps.ids('obs'),
40
+ kind: 'followup',
41
+ summary:
42
+ `PR attachment hosting failed for ${designation.payload.filename} ` +
43
+ `(${artifact.kind}@${artifact.rev}): ${errorMessage(error)}`,
44
+ refs: [`${artifact.kind}@${artifact.rev}`, ...extraRefs],
45
+ },
46
+ })
47
+ events.push(observed)
48
+ } catch {
49
+ // Hosting is a best-effort review affordance. A secondary failure to
50
+ // record its follow-up must not hide the complete text projection or turn
51
+ // a verification/finalize result red.
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Host every newly designated image independently. Text projection never
57
+ * depends on this operation; provider failures become follow-up observations.
58
+ * A successful external upload's durable event append is intentionally not
59
+ * swallowed, preserving deterministic adoption across the upload/write crash
60
+ * window.
61
+ */
62
+ export async function preparePrAttachments(
63
+ deps: PrAttachmentDeps,
64
+ events: AbEvent[],
65
+ prUrl: string,
66
+ ): Promise<void> {
67
+ const target = frozenPrImageHost(events)
68
+ if (target === undefined) return
69
+
70
+ const designations = currentPrAttachments(events)
71
+ const hosted = hostedPrAttachments(events, designations)
72
+ for (const designation of designations) {
73
+ if (!designation.payload.mediaType.startsWith('image/')) continue
74
+ if (hosted.has(designation.seq)) continue
75
+
76
+ let asset: HostedPrAttachmentAsset
77
+ try {
78
+ const capability = deps.forge.prAttachments
79
+ if (capability === undefined) {
80
+ throw new Error(`forge ${deps.forge.name} does not support PR attachment hosting`)
81
+ }
82
+ const ref = designation.payload.artifact
83
+ const artifact = await deps.store.getArtifact(deps.env.build, ref.kind, ref.rev)
84
+ if (artifact === null) {
85
+ throw new Error(`designated artifact ${ref.kind}@${ref.rev} is missing`)
86
+ }
87
+
88
+ asset = hostedPrAttachmentAssetSchema.parse(
89
+ await capability.upload({
90
+ workspacePath: deps.workspacePath,
91
+ target,
92
+ prUrl,
93
+ attachment: designation.payload,
94
+ content: artifact.content,
95
+ sha256: artifact.meta.blobRef,
96
+ }),
97
+ )
98
+ if (
99
+ asset.provider !== target.provider ||
100
+ asset.repository !== target.repository ||
101
+ asset.releaseId !== target.releaseId
102
+ ) {
103
+ throw new Error('PR attachment host returned a deletion handle for a different target')
104
+ }
105
+ } catch (error) {
106
+ await recordHostingFailure(deps, events, designation, error)
107
+ continue
108
+ }
109
+
110
+ const assetRef =
111
+ `${asset.provider}:${asset.repository}:release/${asset.releaseId}:` + `asset/${asset.assetId}`
112
+ let event: EventEnvelope<'pr-attachment.hosted'>
113
+ try {
114
+ event = await deps.store.append(deps.env.build, {
115
+ actor: KERNEL,
116
+ type: 'pr-attachment.hosted',
117
+ payload: { designationSeq: designation.seq, asset },
118
+ })
119
+ } catch (error) {
120
+ // A late attachment has no phase retry after its external upload. Leave
121
+ // an exact cleanup pointer before preserving finalize's throw-and-adopt
122
+ // behavior for this upload/write crash window.
123
+ await recordHostingFailure(
124
+ deps,
125
+ events,
126
+ designation,
127
+ new Error(
128
+ `public copy ${assetRef} was uploaded, but its durable hosted fact ` +
129
+ `could not be recorded: ${errorMessage(error)}`,
130
+ ),
131
+ [assetRef],
132
+ )
133
+ throw error
134
+ }
135
+ events.push(event)
136
+ hosted.set(designation.seq, event)
137
+ }
138
+ }
@@ -0,0 +1,124 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+ import {
3
+ currentPrAttachments,
4
+ frozenPrImageHost,
5
+ hostedPrAttachments,
6
+ } from '../kernel/pr-attachments'
7
+ import { normalizeVerifyCompletion } from '../events/payloads'
8
+ import type { CliEnv } from './env'
9
+
10
+ /** Replace controls that could alter a forge-rendered comment. */
11
+ export function printableCommentText(value: string): string {
12
+ let out = ''
13
+ for (const character of value) {
14
+ const code = character.codePointAt(0)!
15
+ if (character === '\n') out += character
16
+ else if (code < 0x20 || code === 0x7f) out += `\\u{${code.toString(16)}}`
17
+ else out += character
18
+ }
19
+ return out
20
+ }
21
+
22
+ export function html(value: string): string {
23
+ return printableCommentText(value)
24
+ .replaceAll('&', '&amp;')
25
+ .replaceAll('<', '&lt;')
26
+ .replaceAll('>', '&gt;')
27
+ .replaceAll('"', '&quot;')
28
+ .replaceAll("'", '&#39;')
29
+ }
30
+
31
+ export function shellQuote(value: string): string {
32
+ return `'${value.replaceAll("'", `'"'"'`)}'`
33
+ }
34
+
35
+ export function renderPrAttachmentSection(env: CliEnv, events: readonly AbEvent[]): string[] {
36
+ const designations = currentPrAttachments(events)
37
+ if (designations.length === 0) return []
38
+
39
+ const target = frozenPrImageHost(events)
40
+ const hosted = hostedPrAttachments(events, designations)
41
+ const lines = [
42
+ '',
43
+ '### PR attachments',
44
+ '',
45
+ 'Download each exact BuildStore revision with the pinned command below.',
46
+ ]
47
+
48
+ for (const designation of designations) {
49
+ const { artifact, filename, mediaType } = designation.payload
50
+ const ref = `${artifact.kind}@${artifact.rev}`
51
+ // Command-scoped parsing rejects flag-shaped values; retain the exact
52
+ // basename while making a leading-dash filename unambiguously a path.
53
+ const outputPath = filename.startsWith('-') ? `./${filename}` : filename
54
+ const command =
55
+ `ab artifact download ${shellQuote(env.build)} ${shellQuote(ref)} ` +
56
+ `--output ${shellQuote(outputPath)} --store ${shellQuote(env.store)}`
57
+ const hostedEvent = hosted.get(designation.seq)
58
+ const imageUrl =
59
+ mediaType.startsWith('image/') &&
60
+ target !== undefined &&
61
+ hostedEvent?.payload.asset.provider === target.provider &&
62
+ hostedEvent.payload.asset.repository === target.repository &&
63
+ hostedEvent.payload.asset.releaseId === target.releaseId
64
+ ? hostedEvent.payload.asset.url
65
+ : undefined
66
+
67
+ lines.push(
68
+ '',
69
+ `#### <code>${html(filename)}</code>`,
70
+ '',
71
+ ...(imageUrl === undefined
72
+ ? []
73
+ : [`<img src="${html(imageUrl)}" alt="PR attachment ${html(filename)}">`, '']),
74
+ `- artifact: <code>${html(ref)}</code>`,
75
+ `- media type: <code>${html(mediaType)}</code>`,
76
+ `<pre><code>${html(command)}</code></pre>`,
77
+ )
78
+ }
79
+ return lines
80
+ }
81
+
82
+ /** The complete PR summary projection, rendered only from durable facts. */
83
+ export function renderPrSummary(env: CliEnv, events: readonly AbEvent[]): string {
84
+ const verdicts: string[] = []
85
+ const verifies: string[] = []
86
+ for (const event of events) {
87
+ if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
88
+ const phase = event.type === 'plan-review.verdict' ? 'plan-review' : 'code-review'
89
+ const count = event.payload.findings.length
90
+ const detail =
91
+ event.payload.verdict === 'revise' ? ` (${count} finding${count === 1 ? '' : 's'})` : ''
92
+ verdicts.push(`- ${phase} r${event.payload.round}: ${event.payload.verdict}${detail}`)
93
+ }
94
+ if (event.type === 'verify.completed') {
95
+ const result = normalizeVerifyCompletion(event.payload)
96
+ const detail =
97
+ result.outcome === 'skipped'
98
+ ? ` — ${html(result.reason ?? '')}`
99
+ : result.report !== undefined
100
+ ? ` — <code>${html(`${result.report.kind}@${result.report.rev}`)}</code>`
101
+ : ''
102
+ verifies.push(
103
+ `- <code>${html(result.step)}</code> (attempt ${result.attempt}): ${result.outcome}${detail}`,
104
+ )
105
+ }
106
+ }
107
+
108
+ return [
109
+ `## Autobuild: ${html(env.build)}`,
110
+ '',
111
+ '### Verdict history',
112
+ ...(verdicts.length > 0 ? verdicts : ['- (none)']),
113
+ '',
114
+ '### Verify',
115
+ ...(verifies.length > 0 ? verifies : ['- (none)']),
116
+ ...renderPrAttachmentSection(env, events),
117
+ '',
118
+ '### Store',
119
+ `- store: <code>${html(env.store)}</code>`,
120
+ `- build: <code>${html(env.build)}</code>`,
121
+ '',
122
+ 'The full audit trail is queryable in the build store (§7.5).',
123
+ ].join('\n')
124
+ }
@@ -0,0 +1,188 @@
1
+ import type { IdSource } from '../ids'
2
+ import { DISPATCHER, KERNEL } from '../events/envelope'
3
+ import type { Forge, WorkspacePublication } from '../ports/types'
4
+ import type { Exec } from '../ports/workspace/git-worktree'
5
+ import { openExecution } from '../processes/execution-settlement'
6
+ import {
7
+ latestUncompletedPublicationRequest,
8
+ publicationLostRecorded,
9
+ publicationRequestCompleted,
10
+ publicationRequestSettled,
11
+ } from '../processes/publication-state'
12
+ import { systemClock, type BuildStore, type Clock } from '../store/types'
13
+ import { completeFinalizePr } from './terminals'
14
+
15
+ export interface PublicationSettlementDeps {
16
+ store: BuildStore
17
+ storeRef: string
18
+ publication?: WorkspacePublication
19
+ forge: Forge
20
+ workspacePath: string
21
+ exec: Exec
22
+ ids: IdSource
23
+ runId: string
24
+ /** Time source for the durable lease-liveness guard; defaults to the
25
+ * system clock. The same injected clock the dispatcher uses for lease
26
+ * liveness. */
27
+ clock?: Clock
28
+ }
29
+
30
+ /** Settle one durable request after the caller has observed environment
31
+ * completion and released its exact execution lease. External effects precede
32
+ * completion facts, making every crash gap safely retryable.
33
+ *
34
+ * Durable guard: before the publish path, the guest execution must be provably
35
+ * over — either the log has no open execution, or the build's lease is
36
+ * absent/expired. Publication is never attempted against a live guest and is
37
+ * never performed twice for one request; no in-memory set is the sole guard. */
38
+ export async function settlePendingPublication(
39
+ deps: PublicationSettlementDeps,
40
+ slug: string,
41
+ ): Promise<void> {
42
+ const publication = deps.publication
43
+ if (publication === undefined) return
44
+ let events = await deps.store.getEvents(slug)
45
+ const request = latestUncompletedPublicationRequest(events)
46
+ if (request === undefined) return
47
+
48
+ const abandoned = publicationRequestSettled(events, request)
49
+ // Consulted for every uncompleted request, not only abandoned ones: a guest
50
+ // that pushed its branch before dying is owed its completion fact, not a
51
+ // re-push or a loss record.
52
+ const alreadyPublished =
53
+ publication.isPublished !== undefined
54
+ ? await publication.isPublished({ sha: request.payload.sha, branch: request.payload.branch })
55
+ : false
56
+ if (abandoned && !alreadyPublished) {
57
+ // Last-chance backstop (AUT-328): the workspace is gone with this request
58
+ // uncompleted. Record the loss durably (deduped per request) instead of
59
+ // returning silently — the exact drop that lost the validate-hosted-
60
+ // dispatcher generation-1 work. The finalize-step publish-failure path
61
+ // below is unchanged: it already settles loudly via its failed outcome.
62
+ if (!publicationLostRecorded(events, request)) {
63
+ await deps.store.append(slug, {
64
+ actor: DISPATCHER,
65
+ type: 'publication.lost',
66
+ payload: {
67
+ request: request.seq,
68
+ operation: request.payload.operation,
69
+ branch: request.payload.branch,
70
+ sha: request.payload.sha,
71
+ reason: 'workspace released with the publication uncompleted',
72
+ },
73
+ })
74
+ }
75
+ return
76
+ }
77
+
78
+ try {
79
+ if (!alreadyPublished) {
80
+ // Never publish against a live guest: require durable proof the guest
81
+ // execution ended (a recorded end or an expired/absent lease). The
82
+ // adapter's process-local active/uncertain sets remain a second belt.
83
+ const record = await deps.store.getBuild(slug)
84
+ const clock = deps.clock ?? systemClock
85
+ const leaseLive =
86
+ record?.lease !== undefined &&
87
+ new Date(record.lease.expiresAt).getTime() > clock().getTime()
88
+ if (openExecution(events) !== null && leaseLive) return
89
+ let ref: string | undefined
90
+ for (const event of events) {
91
+ if (event.type === 'workspace.provisioned') ref = event.payload.ref
92
+ else if (event.type === 'workspace.released') ref = undefined
93
+ }
94
+ if (ref === undefined)
95
+ throw new Error(`build ${slug} has a publication request but no open workspace`)
96
+ await publication.publish({ ref, sha: request.payload.sha, branch: request.payload.branch })
97
+ }
98
+ } catch (error) {
99
+ if (request.payload.operation !== 'finalize-step') throw error
100
+ const detail = error instanceof Error ? error.message : String(error)
101
+ const note = `finalize publication failed: ${detail}`
102
+ await deps.store.append(slug, {
103
+ actor: KERNEL,
104
+ type: 'finalize.step-completed',
105
+ payload: { step: request.payload.step, ok: false, note },
106
+ })
107
+ await deps.store.append(slug, {
108
+ actor: KERNEL,
109
+ type: 'observation.recorded',
110
+ payload: {
111
+ id: deps.ids('o'),
112
+ kind: 'followup',
113
+ summary: `finalize step "${request.payload.step}" failed — needs manual follow-up: ${note}`,
114
+ },
115
+ })
116
+ return
117
+ }
118
+ events = await deps.store.getEvents(slug)
119
+ if (publicationRequestCompleted(events, request)) return
120
+
121
+ if (request.payload.operation === 'implement') {
122
+ await deps.store.append(slug, {
123
+ actor: DISPATCHER,
124
+ type: 'implement.completed',
125
+ payload: {
126
+ round: request.payload.round,
127
+ commits: { base: request.payload.base, head: request.payload.sha },
128
+ artifact: request.payload.artifact,
129
+ },
130
+ })
131
+ return
132
+ }
133
+ if (request.payload.operation === 'reconcile') {
134
+ await deps.store.append(slug, {
135
+ actor: DISPATCHER,
136
+ type: 'reconcile.completed',
137
+ payload: { mergeCommit: request.payload.sha, artifact: request.payload.artifact },
138
+ })
139
+ return
140
+ }
141
+ if (request.payload.operation === 'finalize-step') {
142
+ await deps.store.append(slug, {
143
+ actor: KERNEL,
144
+ type: 'finalize.step-completed',
145
+ payload: { step: request.payload.step, ok: true, headSha: request.payload.sha },
146
+ })
147
+ return
148
+ }
149
+
150
+ const description = await deps.store.getArtifact(
151
+ slug,
152
+ request.payload.description.kind,
153
+ request.payload.description.rev,
154
+ )
155
+ if (description === null) throw new Error(`missing PR description for ${slug}`)
156
+ const text = new TextDecoder().decode(description.content)
157
+ const newline = text.indexOf('\n')
158
+ const title = (newline === -1 ? text : text.slice(0, newline)).replace(/^#+\s*/, '').trim()
159
+ if (title === '') throw new Error(`empty PR title for ${slug}`)
160
+ const created = events.findLast((event) => event.type === 'build.created')
161
+ if (created?.type !== 'build.created') throw new Error(`missing build.created for ${slug}`)
162
+
163
+ await completeFinalizePr(
164
+ {
165
+ store: deps.store,
166
+ env: {
167
+ store: deps.storeRef,
168
+ build: slug,
169
+ phase: 'finalize',
170
+ round: 1,
171
+ session: `dispatcher-${deps.runId}`,
172
+ },
173
+ workspacePath: deps.workspacePath,
174
+ forge: deps.forge,
175
+ exec: deps.exec,
176
+ ids: deps.ids,
177
+ },
178
+ events,
179
+ {
180
+ branch: request.payload.branch,
181
+ baseBranch: created.payload.baseBranch,
182
+ title,
183
+ body: newline === -1 ? '' : text.slice(newline + 1).replace(/^\n+/, ''),
184
+ mergeMessage: text,
185
+ expectedHeadSha: request.payload.sha,
186
+ },
187
+ )
188
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Repository identity and sessionless local-state resolution.
3
+ *
4
+ * Repository identity (SPEC §7.2, §12) is the checkout's normalized origin
5
+ * URL (`normalizeGitRemoteUrl`), not the checkout path: a path-based identity
6
+ * makes two hosts disagree about which repository they operate. A checkout
7
+ * with no `origin` remote falls back to its resolved path, so origin-less
8
+ * local fixtures keep today's behavior. `RepoStatePaths` therefore carries
9
+ * BOTH: `repo` is the store-keyed identity, `checkout` is the physical main
10
+ * checkout every filesystem consumer (config path, plugin loading, forge
11
+ * `repoRoot`, worktree root, live-reload source) keeps using.
12
+ *
13
+ * Records written before the identity change are keyed by checkout path and
14
+ * are NOT migrated; they remain visible where their recorded `repoOrigin`
15
+ * matches the querying checkout (see `buildInRepository`).
16
+ *
17
+ * One resolver owns both concepts because they must agree in linked worktrees:
18
+ * Git's repository/worktree metadata identifies the main checkout, whose
19
+ * `.autobuild/` directory is the implicit state root. Local overrides are
20
+ * normalized against that checkout so the dispatcher and agents cannot
21
+ * interpret a relative path from different working directories.
22
+ */
23
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
24
+ import type { Exec } from '../ports/workspace/git-worktree'
25
+ import type { BuildRecord } from '../store/types'
26
+ import { normalizeGitRemoteUrl } from '../kernel/origin'
27
+
28
+ export { normalizeGitRemoteUrl } from '../kernel/origin'
29
+
30
+ export const LOCAL_STATE_DIR = '.autobuild'
31
+
32
+ export function isRemoteStoreRef(ref: string): boolean {
33
+ return /^https?:\/\//i.test(ref)
34
+ }
35
+
36
+ function absoluteGitPath(path: string, target: string): string {
37
+ return isAbsolute(path) ? resolve(path) : resolve(target, path)
38
+ }
39
+
40
+ /**
41
+ * Resolve the main checkout from Git's repository/worktree topology.
42
+ *
43
+ * A normal checkout (including a submodule or `--separate-git-dir` checkout)
44
+ * has equal Git and common directories, so `--show-toplevel` is authoritative.
45
+ * A linked worktree has a per-worktree Git directory and a shared common
46
+ * directory; Git's worktree registry lists the main checkout first. This avoids
47
+ * assuming the common directory is `<checkout>/.git`, which is false for
48
+ * submodules and separately stored Git directories.
49
+ *
50
+ * Outside Git (or when Git cannot be executed), the resolved target directory
51
+ * is the deterministic fallback.
52
+ */
53
+ export async function resolveMainRepo(targetRepo: string, exec: Exec): Promise<string> {
54
+ const target = resolve(targetRepo)
55
+ try {
56
+ const result = await exec(
57
+ [
58
+ 'git',
59
+ 'rev-parse',
60
+ '--path-format=absolute',
61
+ '--git-dir',
62
+ '--git-common-dir',
63
+ '--show-toplevel',
64
+ ],
65
+ { cwd: target },
66
+ )
67
+ if (result.exitCode !== 0) return target
68
+ const [gitDirRaw, commonDirRaw, topLevelRaw] = result.stdout.trimEnd().split('\n')
69
+ if (!gitDirRaw || !commonDirRaw || !topLevelRaw) return target
70
+
71
+ const gitDir = absoluteGitPath(gitDirRaw, target)
72
+ const commonDir = absoluteGitPath(commonDirRaw, target)
73
+ const topLevel = absoluteGitPath(topLevelRaw, target)
74
+ if (gitDir === commonDir) return topLevel
75
+
76
+ const worktrees = await exec(['git', 'worktree', 'list', '--porcelain', '-z'], { cwd: target })
77
+ if (worktrees.exitCode === 0) {
78
+ const main = worktrees.stdout
79
+ .split('\0')
80
+ .find((entry) => entry.startsWith('worktree '))
81
+ ?.slice('worktree '.length)
82
+ if (main) return absoluteGitPath(main, target)
83
+ }
84
+
85
+ // Old Git versions without porcelain -z still have the ordinary linked
86
+ // layout. Only derive from dirname when the common dir is literally .git;
87
+ // otherwise the current worktree is safer than writing inside Git metadata.
88
+ return basename(commonDir) === '.git' ? dirname(commonDir) : topLevel
89
+ } catch {
90
+ return target
91
+ }
92
+ }
93
+
94
+ export interface RepoStatePaths {
95
+ /** Repository identity in BuildStore records/journals: the checkout's
96
+ * normalized origin URL, falling back to the resolved checkout path only
97
+ * when the checkout has no origin remote. Opaque to the store schema. */
98
+ repo: string
99
+ /** The physical main checkout — the value `repo` held before the origin
100
+ * identity. Every filesystem consumer (config path, plugin loading, forge
101
+ * `repoRoot`, worktree root, live-reload source) uses this. */
102
+ checkout: string
103
+ /** The only implicit local state root. */
104
+ defaultLocalRoot: string
105
+ /** Normalized local path, or an unchanged HTTP(S) URL. */
106
+ storeRef: string
107
+ /** Root for local-only state (tickets and worktrees). */
108
+ localStateRoot: string
109
+ /** Local scratch root used by GitWorktreeProvider. */
110
+ worktreeRoot: string
111
+ }
112
+
113
+ function nonBlank(value: string | undefined): string | undefined {
114
+ return value !== undefined && value.trim() !== '' ? value : undefined
115
+ }
116
+
117
+ /**
118
+ * Select state with one precedence rule for every sessionless command:
119
+ * non-blank explicit `--store` > non-blank `AB_STORE` > repository-local default.
120
+ *
121
+ * A local selection relocates the whole local tree, including worktrees. A
122
+ * remote store has no filesystem root, so its worktrees remain local beneath
123
+ * the repository's implicit state root.
124
+ */
125
+ export function resolveRepoStatePaths(opts: {
126
+ /** Repository identity: a normalized origin URL, or a local checkout path. */
127
+ repo: string
128
+ /** The physical checkout. Defaults to `repo` so origin-less fixtures —
129
+ * where identity IS the path — need no extra argument. */
130
+ checkout?: string
131
+ storeRef?: string
132
+ envStore?: string
133
+ }): RepoStatePaths {
134
+ const checkout = resolve(opts.checkout ?? opts.repo)
135
+ const defaultLocalRoot = join(checkout, LOCAL_STATE_DIR)
136
+ const selected = nonBlank(opts.storeRef) ?? nonBlank(opts.envStore) ?? defaultLocalRoot
137
+ const remote = isRemoteStoreRef(selected)
138
+ const storeRef = remote ? selected : resolve(checkout, selected)
139
+ const localStateRoot = remote ? defaultLocalRoot : storeRef
140
+ return {
141
+ repo: opts.repo,
142
+ checkout,
143
+ defaultLocalRoot,
144
+ storeRef,
145
+ localStateRoot,
146
+ worktreeRoot: join(localStateRoot, 'worktrees'),
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The normalized origin remote of a checkout, or undefined when the checkout
152
+ * has no `origin` remote or git fails (non-zero exit or thrown). Never throws.
153
+ */
154
+ export async function resolveRepoOrigin(repo: string, exec: Exec): Promise<string | undefined> {
155
+ try {
156
+ const result = await exec(['git', 'remote', 'get-url', 'origin'], { cwd: repo })
157
+ if (result.exitCode !== 0) return undefined
158
+ const raw = result.stdout.trim()
159
+ return raw === '' ? undefined : normalizeGitRemoteUrl(raw)
160
+ } catch {
161
+ return undefined
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Whether a build record belongs to the repository checked out at `checkout`.
167
+ * Identity is the checkout's normalized origin (falling back to the checkout
168
+ * path when there is no origin remote); for new records `record.repo` IS that
169
+ * origin, so equality is the whole test. Records written before the origin
170
+ * identity are keyed by checkout path and are NOT migrated (decision
171
+ * 2026-09-10); a mismatch is forgiven only when the record carries a
172
+ * normalized origin equal to the checkout's identity — a differently located
173
+ * checkout of the same repository (a sandbox guest, a second host clone) is
174
+ * the same repository, while a different origin (or an origin-less record)
175
+ * is foreign.
176
+ */
177
+ export async function buildInRepository(
178
+ record: BuildRecord,
179
+ checkout: string,
180
+ exec: Exec,
181
+ ): Promise<boolean> {
182
+ const origin = await resolveRepoOrigin(checkout, exec)
183
+ const identity = origin ?? resolve(checkout)
184
+ if (record.repo === identity) return true
185
+ if (record.repoOrigin === undefined) return false
186
+ // The recorded side is normalized too (idempotent for records written by
187
+ // this code) so a guard never depends on the writer's normalizer vintage —
188
+ // an ssh-spelled recorded origin still equals the https spelling computed
189
+ // here.
190
+ return normalizeGitRemoteUrl(record.repoOrigin) === identity
191
+ }
192
+
193
+ /** Resolve repository identity, then select all state paths from it.
194
+ *
195
+ * Identity is the resolved main checkout's normalized origin remote, falling
196
+ * back to the checkout path when the checkout has no origin (local fixtures,
197
+ * tests). `checkout` carries the resolved path for every filesystem
198
+ * consumer. */
199
+ export async function resolveRepoState(opts: {
200
+ targetRepo: string
201
+ exec: Exec
202
+ storeRef?: string
203
+ envStore?: string
204
+ }): Promise<RepoStatePaths> {
205
+ const checkout = await resolveMainRepo(opts.targetRepo, opts.exec)
206
+ const origin = await resolveRepoOrigin(checkout, opts.exec)
207
+ return resolveRepoStatePaths({
208
+ repo: origin ?? checkout,
209
+ checkout,
210
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
211
+ ...(opts.envStore !== undefined ? { envStore: opts.envStore } : {}),
212
+ })
213
+ }