@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
@@ -44,7 +44,7 @@ export async function requirePostMutationTicket(
44
44
  export function ticketListCriteria(
45
45
  config: Config,
46
46
  filters: { state?: string; labels?: string[] },
47
- ): { state?: string; labels?: string[] } {
47
+ ): { state?: string; labels?: string[]; assignee?: string } {
48
48
  return filters.state === undefined && filters.labels === undefined
49
49
  ? readyCriteria(config)
50
50
  : filters
@@ -86,7 +86,14 @@ export class HostedTicketSource implements TicketSource {
86
86
  return schema.parse(await response.json())
87
87
  }
88
88
 
89
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
89
+ async listReady(criteria: {
90
+ labels?: string[]
91
+ state?: string
92
+ assignee?: string
93
+ }): Promise<TicketListing> {
94
+ if (criteria.assignee !== undefined) {
95
+ throw new Error('the hosted ticket source does not support an assignee gate')
96
+ }
90
97
  const listing = await this.request('list-ready', criteria, ticketListingWireSchema)
91
98
  return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
92
99
  }
@@ -122,12 +122,27 @@ export interface TicketUpdate {
122
122
  labels?: string[]
123
123
  }
124
124
 
125
+ /** One workflow state of a source that can enumerate them. */
126
+ export interface TicketStateInfo {
127
+ name: string
128
+ /** Tickets currently in the state. */
129
+ tickets: number
130
+ /** One-line purpose recorded when the state was created, if any. */
131
+ about?: string
132
+ }
133
+
125
134
  export interface TicketSource {
126
135
  readonly name: string
127
136
  /** Ready tickets matching the dispatch criteria (label/state — §3.3), plus
128
137
  * actionable diagnostics for individual source records excluded as invalid.
129
138
  * Fatal source failures and invariants still reject the call. */
130
- listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing>
139
+ listReady(criteria: {
140
+ labels?: string[]
141
+ state?: string
142
+ /** `me` (the API key's owner) or a user's email; only sources that can honour it
143
+ * accept it — others reject `[tickets].readyAssignee` at config parse. */
144
+ assignee?: string
145
+ }): Promise<TicketListing>
131
146
  get(id: string): Promise<Ticket | null>
132
147
  /** Claim-before-launch (§12): acquires nonterminal work and returns true.
133
148
  * Returns false for an unknown, already-claimed/contested, or terminal ticket.
@@ -154,6 +169,22 @@ export interface TicketSource {
154
169
  * are initiation, so §13's "never consulted mid-build" is untouched.
155
170
  */
156
171
  dependencyStates(ids: string[]): Promise<DependencyState[]>
172
+ /**
173
+ * Optional state discovery (`ab ticket states`): every state the source has,
174
+ * lifecycle states first in workflow order, then the rest alphabetically.
175
+ * Sources that cannot enumerate their states omit it.
176
+ */
177
+ listStates?(): Promise<TicketStateInfo[]>
178
+ /**
179
+ * Optional state creation (`ab ticket state create`). Idempotent: an existing
180
+ * state is reported with `created: false`, and a supplied `about` replaces its
181
+ * purpose. Rejects a malformed or case-colliding name without creating anything.
182
+ * Named `addState` because adapters already hold a `createState` option field.
183
+ */
184
+ addState?(
185
+ name: string,
186
+ opts?: { about?: string },
187
+ ): Promise<{ state: TicketStateInfo; created: boolean }>
157
188
  }
158
189
 
159
190
  // ── Workspace (SPEC §3.2, §7) ────────────────────────────────────────────────
@@ -177,8 +208,11 @@ export interface WorkspaceProvisionResult extends WorkspaceHandle {
177
208
 
178
209
  export interface WorkspacePublication {
179
210
  /** Observe whether the exact requested commit already reached the durable
180
- * remote branch after an interrupted acknowledgement. */
181
- isPublished?(input: { sha: string; branch: string }): Promise<boolean>
211
+ * remote branch after an interrupted acknowledgement. `ref` optionally
212
+ * names the workspace whose git database the probe runs against — used by
213
+ * the operator-sandbox publication path, whose provider probes from the
214
+ * sandbox checkout; the build-path callers omit it. */
215
+ isPublished?(input: { ref?: string; sha: string; branch: string }): Promise<boolean>
182
216
  /** Publish exactly one commit to exactly one branch; no arbitrary command
183
217
  * surface is exposed to the dispatcher or build child. */
184
218
  publish(input: { ref: string; sha: string; branch: string }): Promise<void>
@@ -221,6 +255,14 @@ export interface WorkspaceProvider {
221
255
  readonly orchestratorSandbox?: OperatorSandboxExecution
222
256
  /** Trusted dispatcher-only publication capability for remote workspaces. */
223
257
  readonly publication?: WorkspacePublication
258
+ /** Publication capability for the operator sandbox (PR-only publication):
259
+ * deliberately distinct from `publication`, which is the dispatcher's
260
+ * remote-workspace discriminator — its presence reclassifies every build
261
+ * workspace as remote and reroutes build publication. `sandboxPublication`
262
+ * is never consulted by the dispatcher; it is consumed only by the
263
+ * operator-sandbox publish service, and present only on providers that can
264
+ * host operator sandboxes. */
265
+ readonly sandboxPublication?: WorkspacePublication
224
266
  /** Optional only for remote/disposable providers. */
225
267
  readonly recovery?: WorkspaceRecovery
226
268
  provision(opts: {
@@ -424,6 +466,10 @@ export interface AgentTurnFailure {
424
466
  /** Additive structured routing cause. Legacy plugin runners may omit it;
425
467
  * their existing permanent bit then supplies the conservative behavior. */
426
468
  cause?: AgentFailureCause
469
+ /** Set with cause 'configuration' when the runtime's CLI executable is not
470
+ * installed. This is the one configuration failure a configured alternate
471
+ * may serve; every other configuration failure still stops the chain. */
472
+ missingExecutable?: true
427
473
  }
428
474
 
429
475
  /** A turn that reached a provider/runtime-declared failure while retaining an
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The full capability objects for the builtin workspace providers (AUT-516).
3
+ * The parse-time subset lives in `config/schema.ts`'s
4
+ * `BUILTIN_WORKSPACE_PROVIDER_CONFIG` because config validation must be
5
+ * statically reachable without the registry; this module assembles the FULL
6
+ * declarations — including the registry-seam behaviors — and is attached to
7
+ * the builtin registrations by `plugins/registry.ts`.
8
+ *
9
+ * Import graph discipline: this module imports `config/schema.ts`,
10
+ * `git-worktree.ts`, `vercel-sandbox.ts`, and the shared vercel capability
11
+ * module; nothing imports it from `schema.ts`, so no cycle exists. The
12
+ * `vercel-sandbox` entry's capabilities are the shared
13
+ * `VERCEL_SANDBOX_CAPABILITIES` object (AUT-517) — the plugin package
14
+ * references the same object, so the declarations cannot drift. The
15
+ * construction-site `configRefusal` for git-worktree is set EXPLICITLY here,
16
+ * not copied from the table's parse-site `configRefusalMessage` — the two
17
+ * strings differ by design (the parse-site message adds the remediation
18
+ * clause; the construction-site one does not).
19
+ *
20
+ * This `BUILTINS` record is the single source of truth for which builtins
21
+ * exist: `plugins/registry.ts` derives its builtin workspace-provider set
22
+ * from `builtinWorkspaceProviderNames()` (AUT-579), so adding an entry here
23
+ * (plus the config-table half that AUT-573's parity tests pin) completes
24
+ * the registration step — no registry edit is required.
25
+ */
26
+ import { join, resolve } from 'node:path'
27
+ import type { VercelSandboxConfig, WorkspaceConfig } from '../../config/schema'
28
+ import type { WorkspaceProviderCapabilities } from './provider-capabilities'
29
+ import type { CreateWorkspaceProviderOptions } from './create'
30
+ import { GitWorktreeProvider } from './git-worktree'
31
+ import { VercelSandboxProvider } from './vercel-sandbox'
32
+ import { VERCEL_SANDBOX_CAPABILITIES } from './vercel-capabilities'
33
+ import type { WorkspaceProvider } from '../types'
34
+
35
+ /** The git-worktree construction-site refusal: no remediation clause, unlike
36
+ * the parse-site message in the config table. */
37
+ const GIT_WORKTREE_CONSTRUCTION_REFUSAL =
38
+ '[workspace.config] is not supported by the builtin "git-worktree" provider'
39
+
40
+ interface BuiltinWorkspaceProviderDeclaration {
41
+ capabilities: WorkspaceProviderCapabilities
42
+ builtinFactory: (
43
+ config: WorkspaceConfig,
44
+ opts: CreateWorkspaceProviderOptions,
45
+ parsed: unknown,
46
+ ) => WorkspaceProvider | Promise<WorkspaceProvider>
47
+ }
48
+
49
+ const BUILTINS: Record<string, BuiltinWorkspaceProviderDeclaration> = {
50
+ 'git-worktree': {
51
+ capabilities: {
52
+ configRefusal: GIT_WORKTREE_CONSTRUCTION_REFUSAL,
53
+ },
54
+ builtinFactory(_config, opts) {
55
+ // Its provider config is always `{}`: the refusal (parse site and
56
+ // construction site alike) has already rejected anything else.
57
+ return new GitWorktreeProvider({
58
+ root: resolve(opts.worktreeRoot),
59
+ sandboxRoot:
60
+ opts.sandboxRoot ?? resolve(join(opts.worktreeRoot, '..', 'orchestrator-sandboxes')),
61
+ setupCommand: opts.sandboxSetupCommand,
62
+ sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
63
+ envSource: opts.env,
64
+ })
65
+ },
66
+ },
67
+ 'vercel-sandbox': {
68
+ capabilities: VERCEL_SANDBOX_CAPABILITIES,
69
+ builtinFactory(_config, opts, parsed) {
70
+ // storeRef/storeToken are guaranteed present: the pre-split
71
+ // storeRequirements check in createWorkspaceProvider ran before
72
+ // construction.
73
+ return new VercelSandboxProvider({
74
+ config: parsed as VercelSandboxConfig,
75
+ env: opts.env,
76
+ storeRef: opts.storeRef!,
77
+ storeToken: opts.storeToken!,
78
+ repo: resolve(opts.repoRoot),
79
+ runtimeReferences: opts.runtimeReferences ?? [],
80
+ setupCommand: opts.sandboxSetupCommand,
81
+ sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
82
+ ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
83
+ ...(opts.remoteBranchHead !== undefined ? { remoteBranchHead: opts.remoteBranchHead } : {}),
84
+ })
85
+ },
86
+ },
87
+ }
88
+
89
+ /** Every builtin provider name, for parity checks over the declaration
90
+ * tables (AUT-573): the parse-time table in `config/schema.ts` and the FULL
91
+ * declarations here must cover the same provider set. */
92
+ export function builtinWorkspaceProviderNames(): readonly string[] {
93
+ return Object.keys(BUILTINS)
94
+ }
95
+
96
+ /** The FULL capability object of a builtin workspace provider, `undefined`
97
+ * for any other name. Used by pre-registry seams (guest probe, init
98
+ * preflight) that cannot consult the plugin registry. */
99
+ export function builtinWorkspaceProviderCapabilities(
100
+ name: string,
101
+ ): WorkspaceProviderCapabilities | undefined {
102
+ return BUILTINS[name]?.capabilities
103
+ }
104
+
105
+ /** The builtin registration extras for `plugins/registry.ts`: the capability
106
+ * object plus the host-owned construction closure. */
107
+ export function builtinWorkspaceProviderRegistration(
108
+ name: string,
109
+ ): BuiltinWorkspaceProviderDeclaration | undefined {
110
+ const builtin = BUILTINS[name]
111
+ return builtin === undefined
112
+ ? undefined
113
+ : { capabilities: builtin.capabilities, builtinFactory: builtin.builtinFactory }
114
+ }
@@ -1,11 +1,17 @@
1
- import { join, resolve } from 'node:path'
2
- import { vercelSandboxConfigSchema, type WorkspaceConfig } from '../../config/schema'
1
+ import { resolve } from 'node:path'
2
+ import { SANDBOX_FORBIDDEN_ENV } from './operator-sandbox'
3
+ import type { WorkspaceConfig } from '../../config/schema'
3
4
  import type { PluginRegistry } from '../../plugins/registry'
4
5
  import type { WorkspaceProvider } from '../types'
5
6
  import type { BuildExecution } from './build-execution'
6
- import { GitWorktreeProvider } from './git-worktree'
7
+ import {
8
+ currentRuntimeReferences,
9
+ runtimeProvisioningMap,
10
+ runtimeProvisioningMissingMessage,
11
+ sandboxForbiddenEnvMessage,
12
+ } from './provider-capabilities'
7
13
  import { LocalBuildExecution } from './local-build-execution'
8
- import { type RuntimeReferencesSource, VercelSandboxProvider } from './vercel-sandbox'
14
+ import type { RuntimeReferencesSource } from './vercel-sandbox'
9
15
 
10
16
  export interface CreateWorkspaceProviderOptions {
11
17
  registry: PluginRegistry
@@ -31,6 +37,11 @@ export interface CreateWorkspaceProviderOptions {
31
37
  sandboxSetupCommand?: string
32
38
  sandboxRoot?: string
33
39
  sandboxEnvironmentVariables?: readonly string[]
40
+ /** Whether `[orchestrator].enabled` is set at the call site. Gates the
41
+ * construction-site `sandboxForbiddenEnv` check so the parse-time rule is
42
+ * reproduced exactly (the config validation only runs when the orchestrator
43
+ * is enabled). */
44
+ orchestratorSandboxEnabled?: boolean
34
45
  }
35
46
 
36
47
  export interface WorkspaceRuntime {
@@ -53,44 +64,56 @@ export async function createWorkspaceProvider(
53
64
  )
54
65
  }
55
66
 
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
- })
67
+ // Uniform capability enforcement (AUT-516): every declaration that needs
68
+ // the registry is honoured here, before the builtin/plugin split, where
69
+ // `registration.capabilities`, `opts.storeRef`, and `opts.storeToken` are
70
+ // already in hand — a plugin declaring any of them is enforced too, never
71
+ // accepted by manifest parsing and silently ignored. Order preserves the
72
+ // builtin failure precedence.
73
+ const capabilities = registration.capabilities
74
+ const hasConfig = Object.keys(config.config).length > 0
75
+ if (capabilities?.configRefusal !== undefined && hasConfig) {
76
+ throw new Error(capabilities.configRefusal)
77
+ }
78
+ let parsed: { data: unknown } | undefined
79
+ if (capabilities?.configSchema !== undefined) {
80
+ const result = capabilities.configSchema.safeParse(config.config)
81
+ if (!result.success) {
82
+ throw new Error(`invalid ${config.provider} config: ${result.error.message}`)
71
83
  }
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')
84
+ parsed = result as { data: unknown }
85
+ }
86
+ if (capabilities?.requireRuntimeProvisioning === true && opts.runtimeReferences !== undefined) {
87
+ // `mcp.ts` passes no `runtimeReferences` because it references no runtimes;
88
+ // dispatch and init validation always do.
89
+ const provisioning = runtimeProvisioningMap(parsed !== undefined ? parsed.data : config.config)
90
+ for (const group of currentRuntimeReferences(opts.runtimeReferences)) {
91
+ if (Object.hasOwn(provisioning, group.runtime)) continue
92
+ throw new Error(runtimeProvisioningMissingMessage(group))
93
+ }
94
+ }
95
+ if (opts.orchestratorSandboxEnabled === true && capabilities?.sandboxForbiddenEnv !== undefined) {
96
+ for (const name of opts.sandboxEnvironmentVariables ?? []) {
97
+ if (SANDBOX_FORBIDDEN_ENV.includes(name) || capabilities.sandboxForbiddenEnv.includes(name)) {
98
+ throw new Error(sandboxForbiddenEnvMessage(name))
77
99
  }
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
100
  }
91
- throw new Error(
92
- `workspace provider "${config.provider}" is registered as a builtin but has no constructor`,
93
- )
101
+ }
102
+ if (
103
+ capabilities?.storeRequirements !== undefined &&
104
+ (opts.storeRef === undefined || opts.storeToken === undefined)
105
+ ) {
106
+ throw new Error(capabilities.storeRequirements.constructionMessage)
107
+ }
108
+
109
+ if (registration.owner.kind === 'builtin') {
110
+ const builtinFactory = registration.builtinFactory
111
+ if (builtinFactory === undefined) {
112
+ throw new Error(
113
+ `workspace provider "${config.provider}" is registered as a builtin but has no constructor`,
114
+ )
115
+ }
116
+ return builtinFactory(config, opts, parsed !== undefined ? parsed.data : config.config)
94
117
  }
95
118
 
96
119
  const factory = registration.factory
@@ -101,10 +124,22 @@ export async function createWorkspaceProvider(
101
124
  }
102
125
 
103
126
  try {
127
+ // Conditional spreads keep the existing exactness guarantee: a call site
128
+ // that supplies no seams constructs exactly `{ config, env, repoRoot }`,
129
+ // so plugin factories see no `undefined`-valued additions. With seams,
130
+ // the context widens in place (AUT-560) — the seams the builtin factory
131
+ // receives through `opts` reach the plugin factory through the context.
104
132
  return await factory({
105
133
  config: config.config,
106
134
  env: opts.env,
107
135
  repoRoot: resolve(opts.repoRoot),
136
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
137
+ ...(opts.storeToken !== undefined ? { storeToken: opts.storeToken } : {}),
138
+ ...(opts.runtimeReferences !== undefined
139
+ ? { runtimeReferences: opts.runtimeReferences }
140
+ : {}),
141
+ ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
142
+ ...(opts.remoteBranchHead !== undefined ? { remoteBranchHead: opts.remoteBranchHead } : {}),
108
143
  })
109
144
  } catch (error) {
110
145
  throw new Error(
@@ -19,17 +19,29 @@
19
19
  * version by construction, and source mode packs `distributionRoot()`, whose
20
20
  * package.json is the same file `readDistributionIdentity()` reads. The hosted
21
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`).
22
+ * `x-autobuild-version` or `x-autobuild-protocol-version` differs from the
23
+ * server's is rejected (409) — so guest, store, and dispatcher versions must
24
+ * move together through a release cut (`tools/release.ts`) plus
25
+ * store/dispatcher upgrades; there is no code path that safely mixes versions.
26
+ * Provisioning records the installed distribution's identity stamp
27
+ * (`readDistributionIdentityStamp()`) in the guest's `.distribution-version`
28
+ * marker and reinstalls the archive on a later mismatch, so an upgraded
29
+ * dispatcher retrofits its persistent guests (see `vercel-sandbox.ts`).
30
+ *
31
+ * The stamp carries the sha256 digest of the packed archive alongside the
32
+ * version and protocol, so a deploy that changes guest-visible code or
33
+ * formats without bumping either one still mismatches every marker computed
34
+ * over the previous archive (AUT-600). The archive and its digest are
35
+ * resolved together once per process (`resolveDistributionArchive()`) and
36
+ * the install paths reuse those same bytes, so the marker names exactly the
37
+ * bytes installed and resolution never adds a second pack per tick.
29
38
  */
30
- import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
31
- import { join, resolve } from 'node:path'
39
+ import { createHash } from 'node:crypto'
40
+ import { access, cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
41
+ import { tmpdir } from 'node:os'
42
+ import { dirname, join, resolve } from 'node:path'
32
43
  import { distributionRoot, distributionPath } from '../../distribution'
44
+ import { REMOTE_STORE_PROTOCOL_VERSION } from '../../store/remote/version'
33
45
  import { registryBaseUrl, registryVersionUrl } from '../../registry'
34
46
  import { parseRepoCoordinates } from '../forge/github'
35
47
  import {
@@ -38,19 +50,28 @@ import {
38
50
  githubTokenFromEnv,
39
51
  type GitHubRequest,
40
52
  } from '../forge/github-transport'
41
- import { packageAutobuildDistribution } from './vercel-sandbox'
53
+ import { spawnExec } from './git-worktree'
42
54
 
43
55
  /** The canonical repository the release assets are published to. Shared with
44
56
  * `tools/release.ts`, which uploads them. */
45
57
  export const CANONICAL_REPOSITORY_URL = 'https://github.com/defrex/autobuild'
46
58
 
59
+ /** The shipped manifest `readDistributionPackage()` reads. A bundled
60
+ * deployment must carry this exact file into its function bundle; the hosted
61
+ * service's post-build trace step appends it to the dispatch route's trace. */
62
+ export { distributionPath, distributionRoot } from '../../distribution'
63
+
64
+ export function distributionManifestPath(): string {
65
+ return distributionPath('package.json')
66
+ }
67
+
47
68
  /** The running distribution's package name and version, from the shipped
48
69
  * package.json. Deployed distributions must ship `package.json` — a
49
70
  * deployment requirement the hosted entry point owns. Absence is a hard,
50
71
  * actionable error. */
51
72
  export async function readDistributionPackage(): Promise<{ name: string; version: string }> {
52
73
  try {
53
- const manifest = JSON.parse(await Bun.file(distributionPath('package.json')).text()) as {
74
+ const manifest = JSON.parse(await Bun.file(distributionManifestPath()).text()) as {
54
75
  name?: unknown
55
76
  version?: unknown
56
77
  }
@@ -85,6 +106,59 @@ export async function readDistributionIdentity(): Promise<string> {
85
106
  return (await readDistributionPackage()).version
86
107
  }
87
108
 
109
+ /** The distribution identity stamp the guest marker records: everything the
110
+ * hosted store's skew check compares — the package version
111
+ * (`x-autobuild-version`) *and* the remote-store protocol version
112
+ * (`x-autobuild-protocol-version`; either mismatch is a 409 at handshake) —
113
+ * plus the sha256 digest of the exact packed archive the dispatcher would
114
+ * install, combined into one opaque string. A protocol-only bump, a
115
+ * version-only release, or a code-only deploy that changes the archive bytes
116
+ * without touching either (AUT-600) each count as a mismatch against a guest
117
+ * whose marker carries the older stamp, and any future skew dimension the
118
+ * store's handshake gains must be folded into this stamp the same way or the
119
+ * marker comparison misses it. */
120
+ export async function distributionStampFor(archive: Uint8Array): Promise<string> {
121
+ const digest = createHash('sha256').update(archive).digest('hex')
122
+ return `${await readDistributionIdentity()}+protocol${REMOTE_STORE_PROTOCOL_VERSION}+sha256-${digest}`
123
+ }
124
+
125
+ /** Per-process memo of the running distribution's archive and its digest.
126
+ * Holds the fulfilled promise so concurrent first callers share one
127
+ * resolution; a rejected resolution clears itself so the next call retries
128
+ * instead of a transient fetch/pack failure poisoning a long-lived process. */
129
+ let distributionArchiveMemo: Promise<{ archive: Uint8Array; digest: string }> | undefined
130
+
131
+ /** Resolve the archive the dispatcher would install together with its sha256
132
+ * digest, at most once per process: the memoized resolution wraps
133
+ * `defaultDistributionArchive()` (see the module docs for the source
134
+ * precedence), so repeated stamp reads and installs cost no second pack. */
135
+ export function resolveDistributionArchive(): Promise<{ archive: Uint8Array; digest: string }> {
136
+ if (distributionArchiveMemo === undefined) {
137
+ const pending = defaultDistributionArchive().then((archive) => ({
138
+ archive,
139
+ digest: createHash('sha256').update(archive).digest('hex'),
140
+ }))
141
+ distributionArchiveMemo = pending
142
+ pending.catch(() => {
143
+ if (distributionArchiveMemo === pending) distributionArchiveMemo = undefined
144
+ })
145
+ }
146
+ return distributionArchiveMemo
147
+ }
148
+
149
+ /** Test-only: drop the per-process archive memo so each test resolves its own
150
+ * source. The memo otherwise outlives a single test. */
151
+ export function resetDistributionArchiveCacheForTest(): void {
152
+ distributionArchiveMemo = undefined
153
+ }
154
+
155
+ /** The identity stamp of the running distribution: the version + protocol
156
+ * skew stamp extended with the digest of the memoized running archive. */
157
+ export async function readDistributionIdentityStamp(): Promise<string> {
158
+ const { archive } = await resolveDistributionArchive()
159
+ return distributionStampFor(archive)
160
+ }
161
+
88
162
  /** The release asset a version's distribution ships under. */
89
163
  export function distributionAssetName(version: string): string {
90
164
  return `autobuild-${version}.tgz`
@@ -211,7 +285,7 @@ export async function fetchDistributionReleaseAsset(
211
285
  }
212
286
 
213
287
  /** Directory, relative to a distribution root, where a deployment ships the
214
- * archive it packed at build time (`ab-hosted-store pack-distribution`). A
288
+ * archive it packed at build time (`ab-hosted-dispatcher pack-distribution`). A
215
289
  * bundled deployment — the hosted service on Vercel — has neither `bun` nor a
216
290
  * source tree at runtime, so the archive must be produced while both exist
217
291
  * and carried into the function bundle. */
@@ -221,6 +295,91 @@ export const PREBUILT_DISTRIBUTION_DIR = '.autobuild-dist'
221
295
  * that place it somewhere other than `PREBUILT_DISTRIBUTION_DIR`. */
222
296
  export const DISTRIBUTION_ARCHIVE_ENV = 'AB_DISTRIBUTION_ARCHIVE'
223
297
 
298
+ async function execOrThrow(cmd: string[], cwd: string): Promise<string> {
299
+ const result = await spawnExec(cmd, { cwd })
300
+ if (result.exitCode !== 0) {
301
+ throw new Error(
302
+ `${cmd.join(' ')} exited ${result.exitCode}: ${result.stderr.trim() || result.stdout.trim()}`,
303
+ )
304
+ }
305
+ return result.stdout.trim()
306
+ }
307
+
308
+ /** Manifest fields that name repo-local packaging state and must never reach
309
+ * the packed distribution manifest. `patchedDependencies` entries are resolved
310
+ * by bun against the consuming project's root, and bun 1.4.0 panics
311
+ * (`Option::unwrap`, exit 134) when a consumed manifest declares a patch for a
312
+ * package the consumer tree contains — so a packed manifest carrying the
313
+ * repo's better-auth patch declaration breaks any consumer that installs
314
+ * autobuild next to better-auth. The packed artifact ships no patched
315
+ * package, so the declaration is stripped from the packed manifest while the
316
+ * repo manifest keeps it for workspace installs. The strip is an enforced
317
+ * invariant, not a local detail: the `patchedDependencies` rule in
318
+ * `tools/workspace-manifest-check.ts` rejects a root manifest whose packed
319
+ * dependency set contains a patched package, and the negative control in
320
+ * `packages/core/src/plugin-sdk/package.test.ts` (the packed-distribution
321
+ * better-auth canary) pins the consumed-manifest failure shape this strip
322
+ * guards, so removing the field from this list fails the suite loudly.
323
+ *
324
+ * `devDependencies` is stripped for two reasons. First, the workspace link
325
+ * specifiers it may carry (for example the root's
326
+ * `@defrex/autobuild-postgres-store: workspace:*`) cannot be resolved in the
327
+ * staging tree — it stages only the manifest's `files` set, with no
328
+ * `bun.lock`, no `node_modules`, and no workspace packages — so `bun pm pack`
329
+ * fails there with "Failed to resolve workspace version". Second, the guest
330
+ * installs the distribution with `bun install --production --ignore-scripts`,
331
+ * which ignores devDependencies entirely; the packed artifact ships no
332
+ * dev-only tooling, so the declaration is dead weight in the packed manifest
333
+ * while the repo manifest keeps it for workspace installs. */
334
+ const packedManifestOmittedFields = ['patchedDependencies', 'devDependencies'] as const
335
+
336
+ export async function packageAutobuildDistribution(): Promise<Uint8Array> {
337
+ const staging = await mkdtemp(join(tmpdir(), 'autobuild-pack-staging-'))
338
+ const destination = await mkdtemp(join(tmpdir(), 'autobuild-pack-'))
339
+ try {
340
+ // Pack from a staging copy of the repo's `files` set rather than the repo
341
+ // root: the packed manifest must differ from the repo manifest (above), and
342
+ // rewriting the repo manifest in place would leave a window where tracked
343
+ // files are dirty.
344
+ const manifestPath = join(distributionRoot(), 'package.json')
345
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
346
+ const files = manifest.files
347
+ if (!Array.isArray(files) || files.some((entry) => typeof entry !== 'string')) {
348
+ throw new Error(
349
+ 'Autobuild packaging requires the root package.json to list its files as an array of paths',
350
+ )
351
+ }
352
+ for (const entry of files as string[]) {
353
+ // npm `files` entries may be negated globs (for example
354
+ // `!packages/core/src/**/*.test.ts`, which keeps test files out of the
355
+ // published package). Only positive entries name a path to stage: the
356
+ // negations stay in the staged manifest and `bun pm pack` applies them,
357
+ // so the packed set still excludes those paths.
358
+ if (entry.startsWith('!')) continue
359
+ const target = join(staging, entry)
360
+ await mkdir(dirname(target), { recursive: true })
361
+ await cp(join(distributionRoot(), entry), target, {
362
+ recursive: true,
363
+ errorOnExist: true,
364
+ force: false,
365
+ })
366
+ }
367
+ for (const field of packedManifestOmittedFields) delete manifest[field]
368
+ await writeFile(join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
369
+ await execOrThrow(
370
+ ['bun', 'pm', 'pack', '--ignore-scripts', '--destination', destination],
371
+ staging,
372
+ )
373
+ const archives = (await readdir(destination)).filter((name) => name.endsWith('.tgz'))
374
+ if (archives.length !== 1)
375
+ throw new Error('Autobuild packaging did not produce exactly one archive')
376
+ return new Uint8Array(await readFile(join(destination, archives[0]!)))
377
+ } finally {
378
+ await rm(staging, { recursive: true, force: true })
379
+ await rm(destination, { recursive: true, force: true })
380
+ }
381
+ }
382
+
224
383
  /** Pack the running distribution into `<root>/.autobuild-dist/autobuild-<version>.tgz`
225
384
  * (the same bytes `bun pm pack` produces) and return the archive path. Meant
226
385
  * for a deployment's build step, where `bun` and the source tree exist. */