@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
@@ -28,7 +28,12 @@ import {
28
28
  type SandboxEnvironmentIdentity,
29
29
  type SandboxWaitResult,
30
30
  } from './operator-sandbox'
31
- import type { WorkspaceHandle, WorkspaceProvider, WorkspaceProvisionResult } from '../types'
31
+ import type {
32
+ WorkspaceHandle,
33
+ WorkspaceProvider,
34
+ WorkspaceProvisionResult,
35
+ WorkspacePublication,
36
+ } from '../types'
32
37
 
33
38
  export interface ProvisionRecord {
34
39
  repo: string
@@ -51,10 +56,23 @@ async function pathExists(path: string): Promise<boolean> {
51
56
  export class FakeWorkspaceProvider implements WorkspaceProvider {
52
57
  readonly name = 'fake'
53
58
  readonly orchestratorSandbox: OperatorSandboxExecution
59
+ readonly sandboxPublication: WorkspacePublication
54
60
 
55
61
  /** Journals — public so tests assert directly on call order and args. */
56
62
  readonly provisions: ProvisionRecord[] = []
57
63
  readonly releases: WorkspaceHandle[] = []
64
+ /** Journal — per-spawn guest environments, recorded by the operator-sandbox
65
+ * capability's spawn sites (`exec`, `start`, and the setup-command spawn)
66
+ * just before the child launches. Credential-freeness is asserted against
67
+ * these records directly: what the provider constructed, not what a shell
68
+ * reported. */
69
+ readonly sandboxExecEnvironments: Array<{
70
+ op: 'exec' | 'start' | 'setup'
71
+ command: string
72
+ env: Record<string, string>
73
+ }> = []
74
+ /** Journal — sandbox publication pushes, in call order. */
75
+ readonly publications: Array<{ ref: string; sha: string; branch: string }> = []
58
76
 
59
77
  private readonly root: string
60
78
  private readonly initialBase: WorkspaceBase
@@ -68,6 +86,12 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
68
86
  /** Durable fake branch heads survive release, like real Git branches. */
69
87
  private readonly branchHeads = new Map<string, string>()
70
88
  private readonly failures = new Map<'provision' | 'release', Error>()
89
+ /** Injectable publication failure: while set, `sandboxPublication.publish`
90
+ * throws it on every call (pass `null` to clear). */
91
+ private publicationFailure: Error | null = null
92
+ /** environmentId → the base branch the fresh provision selected, so the
93
+ * publication capability can refuse the base branch as defense in depth. */
94
+ private readonly sandboxBaseBranches = new Map<string, string>()
71
95
 
72
96
  constructor(
73
97
  opts: {
@@ -102,6 +126,30 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
102
126
  stop: () => Promise.resolve({ outcome: 'stopped' as const }),
103
127
  release: (input) => this.sandboxRelease(input),
104
128
  }
129
+ this.sandboxPublication = {
130
+ isPublished: (input) =>
131
+ Promise.resolve(
132
+ this.publications.some(
133
+ (entry) => entry.branch === input.branch && entry.sha === input.sha,
134
+ ),
135
+ ),
136
+ publish: (input) => this.publishSandbox(input),
137
+ }
138
+ }
139
+
140
+ /** Injectable publication failure: while set, the sandbox publication push
141
+ * throws `error` on every call (pass `null` to clear). */
142
+ setPublicationFailure(error: Error | null): void {
143
+ this.publicationFailure = error
144
+ }
145
+
146
+ private async publishSandbox(input: { ref: string; sha: string; branch: string }): Promise<void> {
147
+ if (this.publicationFailure) throw this.publicationFailure
148
+ const baseBranch = this.sandboxBaseBranches.get(input.ref)
149
+ if (baseBranch !== undefined && input.branch === baseBranch) {
150
+ throw new Error(`publication refuses the base branch ${baseBranch}`)
151
+ }
152
+ this.publications.push({ ...input })
105
153
  }
106
154
 
107
155
  /**
@@ -240,6 +288,12 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
240
288
  }): Promise<SandboxEnvironmentIdentity> {
241
289
  const identity = this.sandboxIdentity(input.repo, input.operator)
242
290
  if (await pathExists(join(identity.workspacePath, '.autobuild-sandbox-provisioned'))) {
291
+ // Pre-existing sandboxes provisioned before the info/exclude write
292
+ // landed (AUT-580) keep an unexcluded marker; write the exclusion on
293
+ // every ensure, mirroring GitWorktreeProvider, so the publish
294
+ // service's untracked-inclusive dirty check never counts the marker
295
+ // as dirt.
296
+ await this.excludeProvisioningMarker(identity.workspacePath)
243
297
  return identity
244
298
  }
245
299
  if (this.mode !== 'filesystem') {
@@ -252,7 +306,55 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
252
306
  await rm(identity.workspacePath, { recursive: true, force: true })
253
307
  await cp(input.repo, identity.workspacePath, { recursive: true })
254
308
  try {
309
+ // Real git checkout (publish-service parity): the three git checks the
310
+ // publish service runs through capability.exec (rev-parse, status,
311
+ // merge-base) must work against the fake exactly as against the real
312
+ // providers, which serve a genuine checkout. A plain fixture source is
313
+ // not a git repository, so the fake initializes one from the base
314
+ // branch and makes a seed commit; a source that already IS a git
315
+ // repository is used as-is. baseSha is the base branch's head after
316
+ // provisioning — the same value the real providers resolve.
317
+ if (!(await pathExists(join(identity.workspacePath, '.git')))) {
318
+ await this.runSandboxGit(identity.workspacePath, ['init', '-b', input.baseBranch])
319
+ await this.runSandboxGit(identity.workspacePath, ['config', 'user.name', 'autobuild[bot]'])
320
+ await this.runSandboxGit(identity.workspacePath, [
321
+ 'config',
322
+ 'user.email',
323
+ 'autobuild[bot]@users.noreply.github.com',
324
+ ])
325
+ await this.runSandboxGit(identity.workspacePath, ['add', '-A'])
326
+ const committed = await this.runSandboxGit(identity.workspacePath, [
327
+ 'commit',
328
+ '--allow-empty',
329
+ '-q',
330
+ '-m',
331
+ 'sandbox seed',
332
+ ])
333
+ if (committed.exitCode !== 0) {
334
+ throw new SandboxOperationError(
335
+ 'provision',
336
+ `operator sandbox git seed failed: ${committed.stderr.trim() || committed.stdout.trim()}`,
337
+ )
338
+ }
339
+ }
340
+ const head = await this.runSandboxGit(identity.workspacePath, [
341
+ 'rev-parse',
342
+ `refs/heads/${input.baseBranch}`,
343
+ ])
344
+ if (head.exitCode !== 0 || head.stdout.trim() === '') {
345
+ throw new SandboxOperationError(
346
+ 'provision',
347
+ `operator sandbox could not resolve base branch ${input.baseBranch}: ${head.stderr.trim() || head.stdout.trim() || `exit ${head.exitCode}`}`,
348
+ )
349
+ }
350
+ const baseSha = head.stdout.trim()
351
+ this.sandboxBaseBranches.set(identity.environmentId, input.baseBranch)
255
352
  if (this.sandboxSetupCommand !== undefined && this.sandboxSetupCommand.trim() !== '') {
353
+ this.sandboxExecEnvironments.push({
354
+ op: 'setup',
355
+ command: this.sandboxSetupCommand,
356
+ env: this.sandboxEnv(),
357
+ })
256
358
  const proc = Bun.spawn(['sh', '-c', this.sandboxSetupCommand], {
257
359
  cwd: identity.workspacePath,
258
360
  stdin: 'ignore',
@@ -272,12 +374,71 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
272
374
  )
273
375
  }
274
376
  }
377
+ await this.excludeProvisioningMarker(identity.workspacePath)
378
+ // The marker file write deliberately stays outside
379
+ // excludeProvisioningMarker: that helper is the info/exclude exclusion
380
+ // logic alone (the early-return heal runs it too), while this write is
381
+ // provisioning-specific.
275
382
  await fsWriteFile(join(identity.workspacePath, '.autobuild-sandbox-provisioned'), '')
383
+ return { ...identity, baseSha }
276
384
  } catch (error) {
277
385
  await rm(identity.workspacePath, { recursive: true, force: true })
278
386
  throw error
279
387
  }
280
- return identity
388
+ }
389
+
390
+ /** Idempotently exclude the provisioning marker in the checkout's
391
+ * info/exclude so the publish service's untracked-inclusive dirty check
392
+ * never counts it as dirt (f_c748dc6a). Runs on both ensure paths —
393
+ * fresh provisioning and the marker-exists early return — so sandboxes
394
+ * provisioned before the write landed are healed the next time they are
395
+ * ensured. Mirrors GitWorktreeProvider. */
396
+ private async excludeProvisioningMarker(workspacePath: string): Promise<void> {
397
+ const exclude = await this.runSandboxGit(workspacePath, [
398
+ 'rev-parse',
399
+ '--git-path',
400
+ 'info/exclude',
401
+ ])
402
+ if (exclude.exitCode !== 0 || exclude.stdout.trim() === '') {
403
+ throw new SandboxOperationError(
404
+ 'provision',
405
+ `operator sandbox could not resolve info/exclude: ${exclude.stderr.trim() || `exit ${exclude.exitCode}`}`,
406
+ )
407
+ }
408
+ const excludePath = resolve(workspacePath, exclude.stdout.trim())
409
+ await mkdir(dirname(excludePath), { recursive: true })
410
+ const existingExclude = await fsReadFile(excludePath, 'utf8').then(
411
+ (content) => content,
412
+ () => '',
413
+ )
414
+ if (!existingExclude.split('\n').includes('.autobuild-sandbox-provisioned')) {
415
+ const prefix =
416
+ existingExclude === '' || existingExclude.endsWith('\n')
417
+ ? existingExclude
418
+ : `${existingExclude}\n`
419
+ await fsWriteFile(excludePath, `${prefix}.autobuild-sandbox-provisioned\n`)
420
+ }
421
+ }
422
+
423
+ /** One git invocation inside the sandbox checkout with the same
424
+ * credential-free guest environment as every exec. */
425
+ private async runSandboxGit(
426
+ cwd: string,
427
+ args: string[],
428
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
429
+ const proc = Bun.spawn(['git', ...args], {
430
+ cwd,
431
+ stdin: 'ignore',
432
+ stdout: 'pipe',
433
+ stderr: 'pipe',
434
+ env: this.sandboxEnv(),
435
+ })
436
+ const [stdout, stderr, exitCode] = await Promise.all([
437
+ new Response(proc.stdout).text(),
438
+ new Response(proc.stderr).text(),
439
+ proc.exited,
440
+ ])
441
+ return { exitCode: exitCode ?? -1, stdout, stderr }
281
442
  }
282
443
 
283
444
  private sandboxCwd(handle: SandboxEnvironmentIdentity, cwd: string | undefined): string {
@@ -289,12 +450,14 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
289
450
  request: { command: string; cwd?: string; timeoutSeconds?: number },
290
451
  ): Promise<SandboxCommandResult> {
291
452
  const timeoutSeconds = Math.min(Math.max(request.timeoutSeconds ?? 120, 1), 300)
453
+ const env = this.sandboxEnv()
454
+ this.sandboxExecEnvironments.push({ op: 'exec', command: request.command, env })
292
455
  const proc = Bun.spawn(['sh', '-c', request.command], {
293
456
  cwd: this.sandboxCwd(handle, request.cwd),
294
457
  stdin: 'ignore',
295
458
  stdout: 'pipe',
296
459
  stderr: 'pipe',
297
- env: this.sandboxEnv(),
460
+ env,
298
461
  })
299
462
  const timer = AbortSignal.timeout(timeoutSeconds * 1000)
300
463
  const outcome = await Promise.race([
@@ -331,12 +494,14 @@ export class FakeWorkspaceProvider implements WorkspaceProvider {
331
494
  handle: SandboxEnvironmentIdentity,
332
495
  request: { command: string; cwd?: string },
333
496
  ): Promise<{ commandId: string }> {
497
+ const env = this.sandboxEnv()
498
+ this.sandboxExecEnvironments.push({ op: 'start', command: request.command, env })
334
499
  const proc = Bun.spawn(['sh', '-c', request.command], {
335
500
  cwd: this.sandboxCwd(handle, request.cwd),
336
501
  stdin: 'ignore',
337
502
  stdout: 'pipe',
338
503
  stderr: 'pipe',
339
- env: this.sandboxEnv(),
504
+ env,
340
505
  })
341
506
  const commandId = `sbcmd-${Math.random().toString(36).slice(2, 12)}`
342
507
  const tracked = { proc, stdout: '', stderr: '', exitCode: null as number | null }
@@ -19,7 +19,12 @@ import {
19
19
  type SandboxEnvironmentIdentity,
20
20
  type SandboxWaitResult,
21
21
  } from './operator-sandbox'
22
- import type { WorkspaceHandle, WorkspaceProvider, WorkspaceProvisionResult } from '../types'
22
+ import type {
23
+ WorkspaceHandle,
24
+ WorkspaceProvider,
25
+ WorkspaceProvisionResult,
26
+ WorkspacePublication,
27
+ } from '../types'
23
28
 
24
29
  export interface ExecResult {
25
30
  stdout: string
@@ -107,6 +112,7 @@ function parseWorktreeList(porcelain: string): WorktreeEntry[] {
107
112
  export class GitWorktreeProvider implements WorkspaceProvider {
108
113
  readonly name = 'git-worktree'
109
114
  readonly orchestratorSandbox: OperatorSandboxExecution
115
+ readonly sandboxPublication: WorkspacePublication
110
116
  private readonly root: string
111
117
  private readonly sandboxRoot: string
112
118
  private readonly setupCommand: string | undefined
@@ -124,6 +130,10 @@ export class GitWorktreeProvider implements WorkspaceProvider {
124
130
  constructor(opts: {
125
131
  root: string
126
132
  exec?: Exec
133
+ /** Test-only override of the exit-stamp drain grace
134
+ * (`DEFAULT_SANDBOX_EXIT_DRAIN_GRACE_MS`); production behavior is
135
+ * unchanged. */
136
+ exitDrainGraceMs?: number
127
137
  /** Operator-sandbox worktree root; defaults to a sibling of the build
128
138
  * worktree root inside the local state tree. */
129
139
  sandboxRoot?: string
@@ -141,6 +151,8 @@ export class GitWorktreeProvider implements WorkspaceProvider {
141
151
  this.setupCommand = opts.setupCommand
142
152
  this.sandboxEnvironmentVariables = opts.sandboxEnvironmentVariables ?? []
143
153
  this.sandboxEnvSource = opts.envSource ?? {}
154
+ this.exitDrainGraceMs =
155
+ opts.exitDrainGraceMs ?? GitWorktreeProvider.DEFAULT_SANDBOX_EXIT_DRAIN_GRACE_MS
144
156
  this.exec = opts.exec ?? spawnExec
145
157
  this.orchestratorSandbox = {
146
158
  describe: (input) => Promise.resolve(this.sandboxIdentity(input.repo, input.operator)),
@@ -153,6 +165,10 @@ export class GitWorktreeProvider implements WorkspaceProvider {
153
165
  stop: () => Promise.resolve({ outcome: 'unsupported' as const }),
154
166
  release: (input) => this.sandboxRelease(input),
155
167
  }
168
+ this.sandboxPublication = {
169
+ isPublished: (input) => this.sandboxIsPublished(input),
170
+ publish: (input) => this.publishSandbox(input),
171
+ }
156
172
  }
157
173
 
158
174
  private async realRoot(): Promise<string> {
@@ -477,10 +493,25 @@ export class GitWorktreeProvider implements WorkspaceProvider {
477
493
 
478
494
  /** Main-repo directory owning the worktree at `path`, or null if the path
479
495
  * is gone or not a worktree — the release-is-a-no-op cases. Needed when a
480
- * restarted process releases a worktree it did not provision (§7.4). */
496
+ * restarted process releases a worktree it did not provision (§7.4).
497
+ * A successful `rev-parse --git-common-dir` with empty stdout is a
498
+ * degenerate input — feeding it to `resolve` would target the worktree
499
+ * directory itself and misdirect the subsequent `worktree remove` — so it
500
+ * fails loudly with a `GitError` (same posture as `shaFrom`'s empty-SHA
501
+ * guard) rather than masquerading as a release-is-a-no-op case. The error
502
+ * type diverges deliberately from `excludeProvisioningMarker`'s
503
+ * `SandboxOperationError`: this is provider-level discovery shared by
504
+ * `release` and `sandboxRelease`, not a sandbox operation. */
481
505
  private async discoverRepo(path: string): Promise<string | null> {
482
506
  const result = await this.git(path, ['rev-parse', '--git-common-dir'])
483
507
  if (result.exitCode !== 0) return null
508
+ if (result.stdout.trim() === '') {
509
+ throw new GitError(['-C', path, 'rev-parse', '--git-common-dir'], {
510
+ ...result,
511
+ exitCode: 1,
512
+ stderr: 'git returned no common directory',
513
+ })
514
+ }
484
515
  const commonDir = resolve(path, result.stdout.trim())
485
516
  // The common dir is the main repo's `.git`; worktree commands need the
486
517
  // repo directory itself (a bare common dir already is one).
@@ -536,7 +567,15 @@ export class GitWorktreeProvider implements WorkspaceProvider {
536
567
  baseBranch: string
537
568
  }): Promise<SandboxEnvironmentIdentity> {
538
569
  const identity = this.sandboxIdentity(input.repo, input.operator)
539
- if (await this.isRegisteredWorktree(identity.environmentId, input.repo)) return identity
570
+ if (await this.isRegisteredWorktree(identity.environmentId, input.repo)) {
571
+ // Pre-existing sandboxes provisioned before the info/exclude write
572
+ // landed (AUT-580) keep an unexcluded marker; write the exclusion on
573
+ // every ensure so the publish service's untracked-inclusive dirty
574
+ // check never counts the marker as dirt and the correct baseSha /
575
+ // reset-required diagnostic surfaces.
576
+ await this.excludeProvisioningMarker(identity.workspacePath)
577
+ return identity
578
+ }
540
579
  await mkdir(this.sandboxRoot, { recursive: true })
541
580
  const baseHead = await this.resolveCommit(input.repo, `refs/heads/${input.baseBranch}`)
542
581
  await this.gitOrThrow(input.repo, [
@@ -547,6 +586,7 @@ export class GitWorktreeProvider implements WorkspaceProvider {
547
586
  baseHead,
548
587
  ])
549
588
  try {
589
+ await this.excludeProvisioningMarker(identity.workspacePath)
550
590
  const marker = join(identity.workspacePath, '.autobuild-sandbox-provisioned')
551
591
  const provisioned = await stat(marker).then(
552
592
  () => true,
@@ -572,7 +612,45 @@ export class GitWorktreeProvider implements WorkspaceProvider {
572
612
  await this.git(input.repo, ['worktree', 'prune'])
573
613
  throw error
574
614
  }
575
- return identity
615
+ return { ...identity, baseSha: baseHead }
616
+ }
617
+
618
+ /** Idempotently exclude the provisioning marker in the worktree's
619
+ * info/exclude so it never counts as dirt for the publish service's
620
+ * untracked-inclusive dirty check (f_c748dc6a). Runs on both ensure
621
+ * paths — fresh provisioning and the registered-worktree early return —
622
+ * so sandboxes provisioned before the write landed are healed the next
623
+ * time they are ensured. Errors propagate: a failed exclusion write
624
+ * aborts provisioning, mirroring fail-closed behavior. A successful
625
+ * `rev-parse --git-path` with empty stdout is a degenerate input — feeding
626
+ * it to `resolve` would target the workspace directory itself — so it fails
627
+ * loudly with the same `SandboxOperationError` — and the identical
628
+ * failure string — the fake workspace uses for the same case, keeping
629
+ * the two workspace-port implementations aligned. */
630
+ private async excludeProvisioningMarker(workspacePath: string): Promise<void> {
631
+ const markerName = '.autobuild-sandbox-provisioned'
632
+ const exclude = await this.gitOrThrow(workspacePath, [
633
+ 'rev-parse',
634
+ '--git-path',
635
+ 'info/exclude',
636
+ ])
637
+ if (exclude.stdout.trim() === '') {
638
+ throw new SandboxOperationError(
639
+ 'provision',
640
+ `operator sandbox could not resolve info/exclude: ${exclude.stderr.trim() || `exit ${exclude.exitCode}`}`,
641
+ )
642
+ }
643
+ const excludePath = resolve(workspacePath, exclude.stdout.trim())
644
+ await mkdir(excludePath.slice(0, excludePath.lastIndexOf(sep)), { recursive: true })
645
+ const existing = await fsReadFile(excludePath, 'utf8').then(
646
+ (content) => content,
647
+ () => '',
648
+ )
649
+ const lines = existing.split('\n')
650
+ if (!lines.includes(markerName)) {
651
+ const prefix = existing === '' || existing.endsWith('\n') ? existing : `${existing}\n`
652
+ await fsWriteFile(excludePath, `${prefix}${markerName}\n`)
653
+ }
576
654
  }
577
655
 
578
656
  /** One `sh -c` command inside the sandbox worktree with the credential-free
@@ -650,9 +728,25 @@ export class GitWorktreeProvider implements WorkspaceProvider {
650
728
  * this cap covers the fire-and-forget case — commands that finish but are
651
729
  * never waited on — so the map (and its retained stdout/stderr strings)
652
730
  * stays bounded at ~cap entries' worth of output regardless of how many
653
- * commands the process ever runs. Running entries are never evicted. */
731
+ * commands the process ever runs. Running entries are never evicted, and
732
+ * cap-evicted commands fail `wait` typed `not-found`: the legitimate
733
+ * not-found population under the cap is entries trimmed as the oldest
734
+ * finished, entries already consumed by a terminal wait, and ids from a
735
+ * restarted host. */
654
736
  private static readonly MAX_RETAINED_SANDBOX_COMMANDS = 50
655
737
 
738
+ /** Bound on how long the exit stamp waits for the stdout/stderr pipe
739
+ * readers to drain after the process exits. A normal child closes its
740
+ * pipes on exit and drains immediately; a grandchild that inherited the
741
+ * write end (e.g. `sleep 30 &`) keeps the pipe open long past the
742
+ * child's exit, so the grace bounds the stamp instead of stalling the
743
+ * delivered `exited` result until the grandchild dies. Because the race
744
+ * outcome varies (reader drain vs. grace), stamp landing times are also
745
+ * variable — which is why `evictFinishedBeyondCap` stops at
746
+ * exited-but-unstamped entries instead of treating them as running. */
747
+ private static readonly DEFAULT_SANDBOX_EXIT_DRAIN_GRACE_MS = 250
748
+ private readonly exitDrainGraceMs: number
749
+
656
750
  /** Detached children tracked in-process only; a restarted host reports a
657
751
  * recorded command as gone (`wait` fails typed `not-found`). Eviction uses
658
752
  * the same semantics: the first `wait` that observes `exited` consumes the
@@ -680,38 +774,75 @@ export class GitWorktreeProvider implements WorkspaceProvider {
680
774
 
681
775
  /** While the map exceeds the retention cap, delete the oldest finished
682
776
  * entries in insertion order (`Map` iterates in insertion order). Running
683
- * entries are never evicted — the live procs themselves are the resource. */
777
+ * entries are never evicted — the live procs themselves are the resource.
778
+ * A pass also stops at the first tracked command that has exited but whose
779
+ * drain-gated exit stamp has not yet landed: such an entry is finished,
780
+ * not running, so the stamped entries older than it are unambiguously
781
+ * safe to evict (and are deleted by the walk reaching it), but nothing
782
+ * newer may be selected ahead of it — deleting a newer finished entry
783
+ * while an older one's stamp is still pending would make the victim
784
+ * choice depend on when stamps happen to land. When the pending entry is
785
+ * itself the oldest evictable entry, the pass deletes nothing and defers
786
+ * to its stamp. The stop always resolves — every exited entry is stamped
787
+ * within the drain grace, and every stamp runs this pass again — so the
788
+ * map settles to the cap with victims chosen as the oldest finished
789
+ * entries in insertion order, independent of stamp timing; while a stamp
790
+ * is pending the map may transiently hold the cap plus the entries that
791
+ * finished after that pending entry. */
684
792
  private evictFinishedBeyondCap(): void {
685
793
  if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) return
686
794
  for (const [id, tracked] of this.sandboxChildren) {
687
- if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) break
795
+ if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) return
796
+ // An exited-but-unstamped entry is finished, not running: stop here so
797
+ // no entry newer than it is evicted ahead of it.
798
+ if (tracked.exitCode === null && this.hasExitedProcess(tracked)) return
688
799
  if (tracked.exitCode !== null) this.sandboxChildren.delete(id)
689
800
  }
690
801
  }
691
802
 
803
+ /** Synchronous exit probe for a tracked child: `exitCode` covers a normal
804
+ * exit, `signalCode` a signal kill (where `exitCode` stays null). Both are
805
+ * synchronous on `Bun.Subprocess`, so an eviction pass reads real state at
806
+ * pass time rather than the drain-delayed stamp. */
807
+ private hasExitedProcess(tracked: { proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'> }): boolean {
808
+ return tracked.proc.exitCode !== null || tracked.proc.signalCode !== null
809
+ }
810
+
692
811
  private trackChildStreams(
693
812
  commandId: string,
694
813
  child: { proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'> },
695
814
  ): void {
696
- void (async () => {
697
- const reader = child.proc.stdout.getReader()
698
- const decoder = new TextDecoder()
699
- for (;;) {
700
- const { done, value } = await reader.read()
701
- if (done) break
702
- this.appendChildStream(commandId, 'stdout', decoder.decode(value, { stream: true }))
703
- }
704
- })()
705
- void (async () => {
706
- const reader = child.proc.stderr.getReader()
707
- const decoder = new TextDecoder()
708
- for (;;) {
709
- const { done, value } = await reader.read()
710
- if (done) break
711
- this.appendChildStream(commandId, 'stderr', decoder.decode(value, { stream: true }))
712
- }
713
- })()
714
- void child.proc.exited.then((code) => {
815
+ const drained = Promise.all([
816
+ (async () => {
817
+ const reader = child.proc.stdout.getReader()
818
+ const decoder = new TextDecoder()
819
+ for (;;) {
820
+ const { done, value } = await reader.read()
821
+ if (done) break
822
+ this.appendChildStream(commandId, 'stdout', decoder.decode(value, { stream: true }))
823
+ }
824
+ })(),
825
+ (async () => {
826
+ const reader = child.proc.stderr.getReader()
827
+ const decoder = new TextDecoder()
828
+ for (;;) {
829
+ const { done, value } = await reader.read()
830
+ if (done) break
831
+ this.appendChildStream(commandId, 'stderr', decoder.decode(value, { stream: true }))
832
+ }
833
+ })(),
834
+ ]).catch(() => {})
835
+ void child.proc.exited.then(async (code) => {
836
+ // Stamp `exited` only once the pipe readers have drained what the
837
+ // child wrote, so a terminal `wait` never snapshots truncated output —
838
+ // under load the exit can otherwise be observed before the readers
839
+ // finish appending (seen as a flaky empty `stdout` in the retention
840
+ // test's full-suite run). The grace bounds the stamp when a grandchild
841
+ // keeps the write end open past the child's own exit.
842
+ await Promise.race([
843
+ drained,
844
+ new Promise((resolve) => setTimeout(resolve, this.exitDrainGraceMs)),
845
+ ])
715
846
  const tracked = this.sandboxChildren.get(commandId)
716
847
  if (tracked === undefined) return
717
848
  tracked.exitCode = code ?? -1
@@ -845,4 +976,93 @@ export class GitWorktreeProvider implements WorkspaceProvider {
845
976
  // A local worktree has no snapshot concept; release is always complete.
846
977
  return { snapshots: { outcome: 'confirmed' } }
847
978
  }
979
+
980
+ // ── Operator-sandbox publication (PR-only) ──────────────────
981
+ //
982
+ // The push and its probe run host-side in the sandbox worktree with the
983
+ // host's own forge configuration — exactly the transport a local build's
984
+ // finalize push uses (GitHubForge.pushBranch runs `git push` in the
985
+ // worktree); the only difference is an exact sha rather than HEAD, because
986
+ // publish accepts an explicit commit. No credential is ever handed to the
987
+ // sandbox itself: the sandbox performs no push, the provider does.
988
+
989
+ /** Same shape validation as the vercel publication adapter: an exact
990
+ * commit SHA and a canonical publication branch. */
991
+ private static assertPublicationInput(input: { sha: string; branch: string }): void {
992
+ if (!/^[0-9a-f]{40,64}$/i.test(input.sha) || !/^ab\/[a-z0-9][a-z0-9-]*$/.test(input.branch)) {
993
+ throw new Error('publication requires an exact commit SHA and canonical build branch')
994
+ }
995
+ }
996
+
997
+ /** Linked worktrees share the origin remote configuration with the main
998
+ * repository, so the probe runs from the worktree path itself. */
999
+ private async hasOriginRemote(cwd: string): Promise<boolean> {
1000
+ const result = await this.git(cwd, ['remote'])
1001
+ if (result.exitCode !== 0) return false
1002
+ return result.stdout
1003
+ .split('\n')
1004
+ .map((line) => line.trim())
1005
+ .includes('origin')
1006
+ }
1007
+
1008
+ private async sandboxIsPublished(input: {
1009
+ ref?: string
1010
+ sha: string
1011
+ branch: string
1012
+ }): Promise<boolean> {
1013
+ GitWorktreeProvider.assertPublicationInput(input)
1014
+ if (input.ref === undefined || input.ref === '') {
1015
+ throw new Error('the git-worktree publication probe requires the workspace ref')
1016
+ }
1017
+ const ref = resolve(input.ref)
1018
+ if (await this.hasOriginRemote(ref)) {
1019
+ const result = await this.git(ref, ['ls-remote', 'origin', `refs/heads/${input.branch}`])
1020
+ if (result.exitCode !== 0) {
1021
+ throw new GitError(['-C', ref, 'ls-remote', 'origin', `refs/heads/${input.branch}`], result)
1022
+ }
1023
+ return result.stdout
1024
+ .split('\n')
1025
+ .some((line) => line.trim() === `${input.sha}\trefs/heads/${input.branch}`)
1026
+ }
1027
+ // No origin remote (local-git-forge deployments, where a local finalize
1028
+ // performs no network push): compare the branch locally. Linked
1029
+ // worktrees share all refs with the main repository, so no bookkeeping
1030
+ // beyond the worktree path is needed.
1031
+ const head = await this.resolveOptionalCommit(ref, `refs/heads/${input.branch}`)
1032
+ return head === input.sha
1033
+ }
1034
+
1035
+ private async publishSandbox(input: { ref: string; sha: string; branch: string }): Promise<void> {
1036
+ GitWorktreeProvider.assertPublicationInput(input)
1037
+ const ref = resolve(input.ref)
1038
+ if (await this.hasOriginRemote(ref)) {
1039
+ const args = [
1040
+ '-C',
1041
+ ref,
1042
+ 'push',
1043
+ '--no-verify',
1044
+ 'origin',
1045
+ `${input.sha}:refs/heads/${input.branch}`,
1046
+ ]
1047
+ const result = await this.exec(['git', ...args], {})
1048
+ if (result.exitCode !== 0) throw new GitError(args, result)
1049
+ return
1050
+ }
1051
+ // Local equivalent of the finalize push for deployments without an
1052
+ // origin remote: a ref move guarded so it can never be a
1053
+ // non-fast-forward. The service refuses the base branch before this
1054
+ // seam is reached.
1055
+ const existing = await this.resolveOptionalCommit(ref, `refs/heads/${input.branch}`)
1056
+ if (existing !== null && existing !== input.sha) {
1057
+ if (!(await this.isAncestor(ref, existing, input.sha))) {
1058
+ throw new Error(
1059
+ `refusing to move refs/heads/${input.branch}: its current tip ${existing} ` +
1060
+ `is not an ancestor of ${input.sha}`,
1061
+ )
1062
+ }
1063
+ }
1064
+ const args = ['-C', ref, 'update-ref', `refs/heads/${input.branch}`, input.sha]
1065
+ const result = await this.git(ref, ['update-ref', `refs/heads/${input.branch}`, input.sha])
1066
+ if (result.exitCode !== 0) throw new GitError(args, result)
1067
+ }
848
1068
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pure GitHub origin validation for the remote workspace provider. String
3
+ * logic only — no SDK, no git, no provider module — so capability declarations
4
+ * can reference it without pulling the provider implementation into import
5
+ * graphs that must stay light.
6
+ */
7
+ import { basename } from 'node:path'
8
+
9
+ export function validateVercelGithubOrigin(raw: string): {
10
+ url: string
11
+ host: string
12
+ path: string
13
+ directory: string
14
+ } {
15
+ let url: URL
16
+ try {
17
+ url = new URL(raw)
18
+ } catch {
19
+ throw new Error('vercel-sandbox requires an HTTPS GitHub origin')
20
+ }
21
+ if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') {
22
+ throw new Error('vercel-sandbox requires an HTTPS github.com origin')
23
+ }
24
+ url.username = ''
25
+ url.password = ''
26
+ url.search = ''
27
+ url.hash = ''
28
+ const path = url.pathname.replace(/\.git$/, '').replace(/^\//, '')
29
+ if (!/^[^/]+\/[^/]+$/.test(path)) throw new Error('GitHub origin must name owner/repository')
30
+ return {
31
+ url: `https://github.com/${path}.git`,
32
+ host: 'github.com',
33
+ path: `/${path}.git`,
34
+ directory: basename(path),
35
+ }
36
+ }
@@ -49,6 +49,11 @@ export interface SandboxEnvironmentIdentity {
49
49
  sessionId?: string
50
50
  /** Checkout root inside the environment; tool file paths are rooted here. */
51
51
  workspacePath: string
52
+ /** The base branch head the fresh provision selected, present only on the
53
+ * fresh-provision path (absent on reuse/resume, which never re-resolves
54
+ * it). The publication preconditions compare against it: a publish must
55
+ * be a descendant of the base head at provision or reset time. */
56
+ baseSha?: string
52
57
  }
53
58
 
54
59
  export interface SandboxCommandRequest {
@@ -87,7 +92,8 @@ export class SandboxOperationError extends Error {
87
92
  | 'not-found'
88
93
  | 'environment'
89
94
  | 'reset'
90
- | 'release',
95
+ | 'release'
96
+ | 'publish',
91
97
  message: string,
92
98
  options?: { cause?: unknown },
93
99
  ) {