@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,136 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { isAbsolute, join } from 'node:path'
4
+ import type { WorkspaceBase } from '../../ontology'
5
+ import type { WorkspaceProvider } from '../types'
6
+
7
+ export interface WorkspaceProviderContractHarness {
8
+ provider: WorkspaceProvider
9
+ provision: Parameters<WorkspaceProvider['provision']>[0]
10
+ /** The exact commit-selection evidence the first provision must return. */
11
+ expectedBase: WorkspaceBase
12
+ fixture: { relativePath: string; content: string }
13
+ cleanup?: () => Promise<void>
14
+ }
15
+
16
+ export type WorkspaceProviderContractFactory = () => Promise<WorkspaceProviderContractHarness>
17
+
18
+ async function withWorkspace(
19
+ factory: WorkspaceProviderContractFactory,
20
+ run: (harness: WorkspaceProviderContractHarness) => Promise<void>,
21
+ ): Promise<void> {
22
+ const harness = await factory()
23
+ let failure: unknown
24
+ try {
25
+ await run(harness)
26
+ } catch (error) {
27
+ failure = error
28
+ }
29
+
30
+ try {
31
+ await harness.cleanup?.()
32
+ } catch (cleanupError) {
33
+ if (failure !== undefined) {
34
+ throw new AggregateError(
35
+ [failure, cleanupError],
36
+ 'WorkspaceProvider contract assertion and cleanup both failed',
37
+ )
38
+ }
39
+ throw cleanupError
40
+ }
41
+ if (failure !== undefined) throw failure
42
+ }
43
+
44
+ async function exists(path: string): Promise<boolean> {
45
+ try {
46
+ await stat(path)
47
+ return true
48
+ } catch (error) {
49
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
50
+ throw error
51
+ }
52
+ }
53
+
54
+ /** Common working-copy semantics for both the fake and git-worktree adapter. */
55
+ export function describeWorkspaceProviderContract(
56
+ name: string,
57
+ factory: WorkspaceProviderContractFactory,
58
+ ): void {
59
+ describe(`WorkspaceProvider contract: ${name}`, () => {
60
+ test('provision returns a usable requested-branch working copy and selected base', async () => {
61
+ await withWorkspace(factory, async (harness) => {
62
+ const result = await harness.provider.provision(harness.provision)
63
+ expect(result.provider).toBe(harness.provider.name)
64
+ expect(result.branch).toBe(harness.provision.branch)
65
+ expect(result.ref).not.toBe('')
66
+ expect(isAbsolute(result.path)).toBe(true)
67
+ expect((await stat(result.path)).isDirectory()).toBe(true)
68
+ expect(await readFile(join(result.path, harness.fixture.relativePath), 'utf8')).toBe(
69
+ harness.fixture.content,
70
+ )
71
+ expect(result.base).toEqual(harness.expectedBase)
72
+
73
+ const writeProbe = join(result.path, 'contract-write-probe.txt')
74
+ await writeFile(writeProbe, 'workspace is writable\n')
75
+ expect(await readFile(writeProbe, 'utf8')).toBe('workspace is writable\n')
76
+ await harness.provider.release(result)
77
+ })
78
+ })
79
+
80
+ test('intact reprovision is non-destructive and reports the existing branch base', async () => {
81
+ await withWorkspace(factory, async (harness) => {
82
+ const first = await harness.provider.provision(harness.provision)
83
+ const writeProbe = join(first.path, 'contract-reprovision-probe.txt')
84
+ await writeFile(writeProbe, 'preserve in-progress work\n')
85
+
86
+ const reprovisioned = await harness.provider.provision(harness.provision)
87
+ expect(reprovisioned.path).toBe(first.path)
88
+ expect(reprovisioned.branch).toBe(harness.provision.branch)
89
+ expect(reprovisioned.base).toEqual({
90
+ source: 'existing',
91
+ sha: first.base.sha,
92
+ })
93
+ expect(await readFile(writeProbe, 'utf8')).toBe('preserve in-progress work\n')
94
+
95
+ await harness.provider.release(reprovisioned)
96
+ })
97
+ })
98
+
99
+ test('reprovision rematerializes a working copy deleted out of band', async () => {
100
+ await withWorkspace(factory, async (harness) => {
101
+ const first = await harness.provider.provision(harness.provision)
102
+ await rm(first.path, { recursive: true, force: true })
103
+ expect(await exists(first.path)).toBe(false)
104
+
105
+ const reprovisioned = await harness.provider.provision(harness.provision)
106
+ expect(reprovisioned.path).toBe(first.path)
107
+ expect(reprovisioned.branch).toBe(harness.provision.branch)
108
+ expect((await stat(reprovisioned.path)).isDirectory()).toBe(true)
109
+ expect(await readFile(join(reprovisioned.path, harness.fixture.relativePath), 'utf8')).toBe(
110
+ harness.fixture.content,
111
+ )
112
+ expect(reprovisioned.base).toEqual({
113
+ source: 'existing',
114
+ sha: first.base.sha,
115
+ })
116
+
117
+ const writeProbe = join(reprovisioned.path, 'contract-recovery-probe.txt')
118
+ await writeFile(writeProbe, 'recovered workspace is writable\n')
119
+ expect(await readFile(writeProbe, 'utf8')).toBe('recovered workspace is writable\n')
120
+ await harness.provider.release(reprovisioned)
121
+ })
122
+ })
123
+
124
+ test('release removes/releases the handle and is safe to call twice', async () => {
125
+ await withWorkspace(factory, async (harness) => {
126
+ const result = await harness.provider.provision(harness.provision)
127
+ expect(await exists(result.path)).toBe(true)
128
+
129
+ await harness.provider.release(result)
130
+ expect(await exists(result.path)).toBe(false)
131
+ await harness.provider.release(result)
132
+ expect(await exists(result.path)).toBe(false)
133
+ })
134
+ })
135
+ })
136
+ }
@@ -0,0 +1,132 @@
1
+ import { join, resolve } from 'node:path'
2
+ import { vercelSandboxConfigSchema, type WorkspaceConfig } from '../../config/schema'
3
+ import type { PluginRegistry } from '../../plugins/registry'
4
+ import type { WorkspaceProvider } from '../types'
5
+ import type { BuildExecution } from './build-execution'
6
+ import { GitWorktreeProvider } from './git-worktree'
7
+ import { LocalBuildExecution } from './local-build-execution'
8
+ import { type RuntimeReferencesSource, VercelSandboxProvider } from './vercel-sandbox'
9
+
10
+ export interface CreateWorkspaceProviderOptions {
11
+ registry: PluginRegistry
12
+ /** Selected local scratch root. Remote stores pass the repository default. */
13
+ worktreeRoot: string
14
+ /** Absolute repository root supplied to plugin factories. */
15
+ repoRoot: string
16
+ env: Record<string, string | undefined>
17
+ /** Required only by remote builtins. */
18
+ storeRef?: string
19
+ storeToken?: string
20
+ /** Host-derived effective routes; consumed only by the built-in sandbox. */
21
+ runtimeReferences?: RuntimeReferencesSource
22
+ /** Checkout-less seams for the built-in sandbox: the repository's HTTPS
23
+ * origin, and a remote branch-head reader returning `undefined` for an
24
+ * absent branch. Absent seams fall back to host `git` from `repoRoot`. */
25
+ origin?: () => Promise<string>
26
+ remoteBranchHead?: (branch: string) => Promise<string | undefined>
27
+ /** Operator-sandbox options (AUT-340), set once at construction: the raw
28
+ * `[commands].setup` shell string, the local-state-tree sandbox root, and
29
+ * the forwarded non-secret variable names. The service never re-receives
30
+ * setup, root, or variables. */
31
+ sandboxSetupCommand?: string
32
+ sandboxRoot?: string
33
+ sandboxEnvironmentVariables?: readonly string[]
34
+ }
35
+
36
+ export interface WorkspaceRuntime {
37
+ provider: WorkspaceProvider
38
+ execution: BuildExecution
39
+ }
40
+
41
+ /** Resolve and lazily construct the configured WorkspaceProvider. Builtin
42
+ * construction remains host-owned because its scratch root comes from store
43
+ * selection rather than plugin configuration. */
44
+ export async function createWorkspaceProvider(
45
+ config: WorkspaceConfig,
46
+ opts: CreateWorkspaceProviderOptions,
47
+ ): Promise<WorkspaceProvider> {
48
+ const available = [...opts.registry.workspaceProviders.keys()].sort()
49
+ const registration = opts.registry.workspaceProviders.get(config.provider)
50
+ if (registration === undefined) {
51
+ throw new Error(
52
+ `unknown workspace provider "${config.provider}"; available providers: ${available.join(', ')}`,
53
+ )
54
+ }
55
+
56
+ if (registration.owner.kind === 'builtin') {
57
+ if (config.provider === 'git-worktree') {
58
+ if (Object.keys(config.config).length > 0) {
59
+ throw new Error(
60
+ '[workspace.config] is not supported by the builtin "git-worktree" provider',
61
+ )
62
+ }
63
+ return new GitWorktreeProvider({
64
+ root: resolve(opts.worktreeRoot),
65
+ sandboxRoot:
66
+ opts.sandboxRoot ?? resolve(join(opts.worktreeRoot, '..', 'orchestrator-sandboxes')),
67
+ setupCommand: opts.sandboxSetupCommand,
68
+ sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
69
+ envSource: opts.env,
70
+ })
71
+ }
72
+ if (config.provider === 'vercel-sandbox') {
73
+ const parsed = vercelSandboxConfigSchema.safeParse(config.config)
74
+ if (!parsed.success) throw new Error(`invalid vercel-sandbox config: ${parsed.error.message}`)
75
+ if (opts.storeRef === undefined || opts.storeToken === undefined) {
76
+ throw new Error('vercel-sandbox requires an HTTPS BuildStore and scoped AB_TOKEN authority')
77
+ }
78
+ return new VercelSandboxProvider({
79
+ config: parsed.data,
80
+ env: opts.env,
81
+ storeRef: opts.storeRef,
82
+ storeToken: opts.storeToken,
83
+ repo: resolve(opts.repoRoot),
84
+ runtimeReferences: opts.runtimeReferences ?? [],
85
+ setupCommand: opts.sandboxSetupCommand,
86
+ sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
87
+ ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
88
+ ...(opts.remoteBranchHead !== undefined ? { remoteBranchHead: opts.remoteBranchHead } : {}),
89
+ })
90
+ }
91
+ throw new Error(
92
+ `workspace provider "${config.provider}" is registered as a builtin but has no constructor`,
93
+ )
94
+ }
95
+
96
+ const factory = registration.factory
97
+ if (factory === undefined) {
98
+ throw new Error(
99
+ `workspace provider "${config.provider}" from plugin "${registration.owner.name}" has no factory`,
100
+ )
101
+ }
102
+
103
+ try {
104
+ return await factory({
105
+ config: config.config,
106
+ env: opts.env,
107
+ repoRoot: resolve(opts.repoRoot),
108
+ })
109
+ } catch (error) {
110
+ throw new Error(
111
+ `workspace provider "${config.provider}" failed to initialize: ${
112
+ error instanceof Error ? error.message : String(error)
113
+ }`,
114
+ { cause: error },
115
+ )
116
+ }
117
+ }
118
+
119
+ /** Resolve provisioning and execution as one workspace-owned runtime. Existing
120
+ * providers remain source-compatible: locally reachable implementations that
121
+ * omit execution receive the shipped child-process capability, while a future
122
+ * sandbox provider can replace it without a local proxy process. */
123
+ export async function createWorkspaceRuntime(
124
+ config: WorkspaceConfig,
125
+ opts: CreateWorkspaceProviderOptions,
126
+ ): Promise<WorkspaceRuntime> {
127
+ const provider = await createWorkspaceProvider(config, opts)
128
+ return {
129
+ provider,
130
+ execution: provider.buildExecution ?? new LocalBuildExecution({ env: opts.env }),
131
+ }
132
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * The Autobuild distribution archive installed into guests.
3
+ *
4
+ * Two sources, one archive layout (`autobuild-<version>.tgz`, the same bytes
5
+ * `bun pm pack` produces and the guest bootstrap extracts):
6
+ *
7
+ * - Usable source checkout: `bun pm pack` from the host tree (local-git
8
+ * consumers, and any dispatcher running from a checkout with a `bun`
9
+ * executable on `PATH` — byte-identical to today). A checkout without `bun`
10
+ * cannot be packed, so a vestigial `.git` directory in a bundled deployment
11
+ * routes to the release asset instead of attempting a pack it cannot run.
12
+ * - Checkout-less origin mode: the dispatcher's own published release asset,
13
+ * fetched from GitHub by the running version, so the guest installs exactly
14
+ * the version the launching dispatcher reports even when no source tree
15
+ * exists on disk.
16
+ *
17
+ * The guest's installed version therefore equals the running distribution's
18
+ * version in both modes: origin mode's release-asset tag is the running
19
+ * version by construction, and source mode packs `distributionRoot()`, whose
20
+ * package.json is the same file `readDistributionIdentity()` reads. The hosted
21
+ * remote store enforces exact version lockstep — a client whose
22
+ * `x-autobuild-version` differs from the server's is rejected (409) — so
23
+ * guest, store, and dispatcher versions must move together through a release
24
+ * cut (`tools/release.ts`) plus store/dispatcher upgrades; there is no code
25
+ * path that safely mixes versions. Provisioning records the installed version
26
+ * in the guest's `.distribution-version` marker and reinstalls the archive on
27
+ * a later mismatch, so an upgraded dispatcher retrofits its persistent guests
28
+ * (see `vercel-sandbox.ts`).
29
+ */
30
+ import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
31
+ import { join, resolve } from 'node:path'
32
+ import { distributionRoot, distributionPath } from '../../distribution'
33
+ import { registryBaseUrl, registryVersionUrl } from '../../registry'
34
+ import { parseRepoCoordinates } from '../forge/github'
35
+ import {
36
+ createGitHubFetchTransport,
37
+ GitHubApiError,
38
+ githubTokenFromEnv,
39
+ type GitHubRequest,
40
+ } from '../forge/github-transport'
41
+ import { packageAutobuildDistribution } from './vercel-sandbox'
42
+
43
+ /** The canonical repository the release assets are published to. Shared with
44
+ * `tools/release.ts`, which uploads them. */
45
+ export const CANONICAL_REPOSITORY_URL = 'https://github.com/defrex/autobuild'
46
+
47
+ /** The running distribution's package name and version, from the shipped
48
+ * package.json. Deployed distributions must ship `package.json` — a
49
+ * deployment requirement the hosted entry point owns. Absence is a hard,
50
+ * actionable error. */
51
+ export async function readDistributionPackage(): Promise<{ name: string; version: string }> {
52
+ try {
53
+ const manifest = JSON.parse(await Bun.file(distributionPath('package.json')).text()) as {
54
+ name?: unknown
55
+ version?: unknown
56
+ }
57
+ if (typeof manifest.version !== 'string' || manifest.version === '') {
58
+ throw new Error('the distribution package.json has no usable version field')
59
+ }
60
+ if (typeof manifest.name !== 'string' || manifest.name === '') {
61
+ throw new Error('the distribution package.json has no usable name field')
62
+ }
63
+ return { name: manifest.name, version: manifest.version }
64
+ } catch (error) {
65
+ const detail = error instanceof Error ? error.message : String(error)
66
+ throw new Error(
67
+ `could not read the running Autobuild version from the distribution package.json: ${detail}; ` +
68
+ 'a deployed distribution must ship package.json — install Autobuild from its packed archive or release',
69
+ { cause: error },
70
+ )
71
+ }
72
+ }
73
+
74
+ async function fileExists(path: string): Promise<boolean> {
75
+ try {
76
+ await access(path)
77
+ return true
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ /** The running distribution's version alone. */
84
+ export async function readDistributionIdentity(): Promise<string> {
85
+ return (await readDistributionPackage()).version
86
+ }
87
+
88
+ /** The release asset a version's distribution ships under. */
89
+ export function distributionAssetName(version: string): string {
90
+ return `autobuild-${version}.tgz`
91
+ }
92
+
93
+ /** Minimal fetch seam for registry reads: the global `fetch` in production. */
94
+ export type RegistryFetch = (
95
+ url: string,
96
+ init: { headers: Record<string, string> },
97
+ ) => Promise<{ status: number; text(): Promise<string>; arrayBuffer(): Promise<ArrayBuffer> }>
98
+
99
+ const registryFetch: RegistryFetch = (url, init) => fetch(url, init)
100
+
101
+ /** Fetch the published npm tarball for `packageName@version`: read the
102
+ * registry's version document, follow its `dist.tarball`, and return the
103
+ * bytes. The tarball roots at `package/`, exactly like `bun pm pack`, so the
104
+ * guest bootstrap extracts it unchanged. Honors `NPM_CONFIG_REGISTRY`. */
105
+ export async function fetchDistributionRegistryTarball(
106
+ packageName: string,
107
+ version: string,
108
+ env: Readonly<Record<string, string | undefined>> = process.env,
109
+ fetchImpl: RegistryFetch = registryFetch,
110
+ ): Promise<Uint8Array> {
111
+ const url = registryVersionUrl(registryBaseUrl(env), packageName, version)
112
+ const document = await fetchImpl(url, { headers: { accept: 'application/json' } })
113
+ if (document.status !== 200) {
114
+ throw new Error(
115
+ `npm registry has no ${packageName}@${version} (HTTP ${document.status} from ${url}); ` +
116
+ 'publish the release first',
117
+ )
118
+ }
119
+ let tarball: unknown
120
+ try {
121
+ tarball = (JSON.parse(await document.text()) as { dist?: { tarball?: unknown } }).dist?.tarball
122
+ } catch (error) {
123
+ throw new Error(`npm registry returned an unreadable document for ${packageName}@${version}`, {
124
+ cause: error,
125
+ })
126
+ }
127
+ if (typeof tarball !== 'string' || tarball === '') {
128
+ throw new Error(`npm registry document for ${packageName}@${version} names no dist.tarball`)
129
+ }
130
+ const download = await fetchImpl(tarball, { headers: { accept: 'application/octet-stream' } })
131
+ if (download.status !== 200) {
132
+ throw new Error(`downloading ${tarball} failed with HTTP ${download.status}`)
133
+ }
134
+ const bytes = new Uint8Array(await download.arrayBuffer())
135
+ if (bytes.byteLength === 0) throw new Error(`${tarball} downloaded no bytes`)
136
+ return bytes
137
+ }
138
+
139
+ /** The published distribution for the running version: the npm registry
140
+ * tarball first, then the GitHub release asset (the route a fork or an
141
+ * unpublished channel still has). A failure names both sources. */
142
+ async function fetchPublishedDistribution(
143
+ env: Readonly<Record<string, string | undefined>>,
144
+ transport: GitHubRequest | undefined,
145
+ fetchImpl: RegistryFetch,
146
+ ): Promise<Uint8Array> {
147
+ const { name, version } = await readDistributionPackage()
148
+ try {
149
+ return await fetchDistributionRegistryTarball(name, version, env, fetchImpl)
150
+ } catch (registryError) {
151
+ try {
152
+ return await fetchDistributionReleaseAsset(version, env, transport)
153
+ } catch (releaseError) {
154
+ const detail = (error: unknown): string =>
155
+ error instanceof Error ? error.message : String(error)
156
+ throw new Error(
157
+ `no published Autobuild ${version} distribution: npm registry — ${detail(registryError)}; ` +
158
+ `GitHub release asset — ${detail(releaseError)}`,
159
+ { cause: releaseError },
160
+ )
161
+ }
162
+ }
163
+ }
164
+
165
+ /** Fetch the published `autobuild-<version>.tgz` release asset for the
166
+ * running version. Uses the dispatcher's GitHub credentials when present
167
+ * (required for a private canonical repository). */
168
+ export async function fetchDistributionReleaseAsset(
169
+ version: string,
170
+ env: Readonly<Record<string, string | undefined>> = process.env,
171
+ transport: GitHubRequest = createGitHubFetchTransport({
172
+ token: githubTokenFromEnv(env),
173
+ }),
174
+ ): Promise<Uint8Array> {
175
+ const coordinates = parseRepoCoordinates(CANONICAL_REPOSITORY_URL)
176
+ if (coordinates === null) {
177
+ throw new Error(`the canonical repository URL is not parseable: ${CANONICAL_REPOSITORY_URL}`)
178
+ }
179
+ const repo = `repos/${encodeURIComponent(coordinates.owner)}/${encodeURIComponent(coordinates.name)}`
180
+ const tag = `v${version}`
181
+ const release = await transport('GET', `${repo}/releases/tags/${tag}`).catch((error: unknown) => {
182
+ if (error instanceof GitHubApiError && error.status === 404) {
183
+ throw new Error(
184
+ `Autobuild release ${tag} has no published GitHub release; origin-mode dispatch ` +
185
+ 'installs the guest distribution from the release asset autobuild-' +
186
+ `${version}.tgz — cut a release with tools/release.ts first`,
187
+ { cause: error },
188
+ )
189
+ }
190
+ throw error
191
+ })
192
+ const assets = (
193
+ release.json as { assets?: { name?: unknown; browser_download_url?: unknown }[] } | undefined
194
+ )?.assets
195
+ const assetName = distributionAssetName(version)
196
+ const asset = (assets ?? []).find((candidate) => candidate.name === assetName)
197
+ if (asset === undefined || typeof asset.browser_download_url !== 'string') {
198
+ throw new Error(
199
+ `Autobuild release ${tag} does not carry the ${assetName} distribution asset; ` +
200
+ 'origin-mode dispatch cannot install a guest distribution without it — ' +
201
+ 'cut a release with tools/release.ts first',
202
+ )
203
+ }
204
+ const download = await transport('GET', asset.browser_download_url, {
205
+ headers: { Accept: 'application/octet-stream' },
206
+ })
207
+ if (download.bytes === undefined) {
208
+ throw new Error(`the ${assetName} release asset downloaded no bytes`)
209
+ }
210
+ return download.bytes
211
+ }
212
+
213
+ /** Directory, relative to a distribution root, where a deployment ships the
214
+ * archive it packed at build time (`ab-hosted-store pack-distribution`). A
215
+ * bundled deployment — the hosted service on Vercel — has neither `bun` nor a
216
+ * source tree at runtime, so the archive must be produced while both exist
217
+ * and carried into the function bundle. */
218
+ export const PREBUILT_DISTRIBUTION_DIR = '.autobuild-dist'
219
+
220
+ /** The environment variable naming an explicit archive file, for deployments
221
+ * that place it somewhere other than `PREBUILT_DISTRIBUTION_DIR`. */
222
+ export const DISTRIBUTION_ARCHIVE_ENV = 'AB_DISTRIBUTION_ARCHIVE'
223
+
224
+ /** Pack the running distribution into `<root>/.autobuild-dist/autobuild-<version>.tgz`
225
+ * (the same bytes `bun pm pack` produces) and return the archive path. Meant
226
+ * for a deployment's build step, where `bun` and the source tree exist. */
227
+ export async function writePrebuiltDistributionArchive(
228
+ root: string = distributionRoot(),
229
+ ): Promise<string> {
230
+ const version = await readDistributionIdentity()
231
+ const archive = await packageAutobuildDistribution()
232
+ const dir = join(root, PREBUILT_DISTRIBUTION_DIR)
233
+ await mkdir(dir, { recursive: true })
234
+ const path = join(dir, distributionAssetName(version))
235
+ await writeFile(path, archive)
236
+ return path
237
+ }
238
+
239
+ /** Locate a prebuilt archive: `AB_DISTRIBUTION_ARCHIVE` when set, else the
240
+ * single `autobuild-*.tgz` under `.autobuild-dist/` of the distribution root
241
+ * or the working directory (a bundled function's root). Null when none. */
242
+ export async function findPrebuiltDistributionArchive(
243
+ env: Readonly<Record<string, string | undefined>> = process.env,
244
+ roots: readonly string[] = [distributionRoot(), process.cwd()],
245
+ ): Promise<string | null> {
246
+ const explicit = env[DISTRIBUTION_ARCHIVE_ENV]?.trim()
247
+ if (explicit) {
248
+ const path = resolve(explicit)
249
+ if (!(await fileExists(path))) {
250
+ throw new Error(`${DISTRIBUTION_ARCHIVE_ENV} names a file that does not exist: ${path}`)
251
+ }
252
+ return path
253
+ }
254
+ for (const root of [...new Set(roots)]) {
255
+ const dir = join(root, PREBUILT_DISTRIBUTION_DIR)
256
+ let names: string[]
257
+ try {
258
+ names = (await readdir(dir)).filter(
259
+ (name) => name.startsWith('autobuild-') && name.endsWith('.tgz'),
260
+ )
261
+ } catch {
262
+ continue
263
+ }
264
+ if (names.length === 0) continue
265
+ if (names.length > 1) {
266
+ throw new Error(
267
+ `${dir} holds more than one distribution archive (${names.sort().join(', ')}); ` +
268
+ 'a deployment ships exactly one',
269
+ )
270
+ }
271
+ return join(dir, names[0]!)
272
+ }
273
+ return null
274
+ }
275
+
276
+ /** Whether a `bun` executable is available to pack a source checkout with.
277
+ * Injectable in tests via `defaultDistributionArchive`. */
278
+ const hasBunExecutable = (): boolean => Bun.which('bun') !== null
279
+
280
+ /**
281
+ * The default guest distribution source, in precedence order: an archive
282
+ * prebuilt for this deployment (`AB_DISTRIBUTION_ARCHIVE` or
283
+ * `.autobuild-dist/`), then a usable source checkout — a `.git` directory
284
+ * *and* a `bun` executable to pack with — then the running version's
285
+ * published distribution: the npm registry tarball, else the GitHub release
286
+ * asset (see module docs). The prebuilt archive comes first because a
287
+ * bundled deployment can carry a vestigial `.git` directory without a `bun`
288
+ * executable to pack with; in that case the published distribution is the
289
+ * route, and when it too is missing the error names the prebuilt archive the
290
+ * deployment should have shipped.
291
+ */
292
+ export async function defaultDistributionArchive(
293
+ env: Readonly<Record<string, string | undefined>> = process.env,
294
+ transport?: GitHubRequest,
295
+ hasBun: () => boolean = hasBunExecutable,
296
+ fetchImpl: RegistryFetch = registryFetch,
297
+ ): Promise<Uint8Array> {
298
+ const prebuilt = await findPrebuiltDistributionArchive(env)
299
+ if (prebuilt !== null) return new Uint8Array(await readFile(prebuilt))
300
+ if (await fileExists(join(distributionRoot(), '.git'))) {
301
+ if (hasBun()) return packageAutobuildDistribution()
302
+ // Vestigial `.git` in a bundled deployment: there is no usable source
303
+ // checkout to pack, so fall through to the release asset — and when that
304
+ // also fails, name the prebuilt archive the deployment should ship
305
+ // instead of leaving an unactionable failure behind.
306
+ const version = await readDistributionIdentity()
307
+ return fetchPublishedDistribution(env, transport, fetchImpl).catch((error: unknown) => {
308
+ throw new Error(
309
+ `no usable source checkout to pack and no prebuilt distribution archive for this deployment; ` +
310
+ `ship .autobuild-dist/autobuild-${version}.tgz with the deployment or point ` +
311
+ `${DISTRIBUTION_ARCHIVE_ENV} at one (see the deployment pack step)`,
312
+ { cause: error },
313
+ )
314
+ })
315
+ }
316
+ return fetchPublishedDistribution(env, transport, fetchImpl)
317
+ }