@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -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/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -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
  ) {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Declarative capabilities a workspace-provider registration can supply, plus
3
+ * the readiness contract those capabilities speak. Core honours these
4
+ * declarations at registry-aware seams without naming any provider, which is
5
+ * what lets a third-party remote provider receive the same treatment as the
6
+ * builtin `vercel-sandbox` registration (AUT-516).
7
+ *
8
+ * This module is a leaf: runtime imports are limited to `config/roles`
9
+ * helpers, everything else is types. `config/schema.ts` imports the message
10
+ * builders below, and `builtin-capabilities.ts` imports `config/schema.ts` —
11
+ * so nothing here may import either, or module evaluation would cycle.
12
+ */
13
+ import type { z } from 'zod'
14
+ import { displayName, tomlKey, type RuntimeReferenceGroup } from '../../config/roles'
15
+ import type { Config } from '../../config/schema'
16
+ import type { Exec } from './git-worktree'
17
+
18
+ /** Host-derived effective routes; consumed only by remote providers. */
19
+ export type RuntimeReferencesSource =
20
+ | readonly RuntimeReferenceGroup[]
21
+ | (() => readonly RuntimeReferenceGroup[])
22
+
23
+ export function currentRuntimeReferences(
24
+ source: RuntimeReferencesSource | undefined,
25
+ ): readonly RuntimeReferenceGroup[] {
26
+ if (source === undefined) return []
27
+ return typeof source === 'function' ? source() : source
28
+ }
29
+
30
+ /** The `[workspace.config.runtimeProvisioning]` map of a parsed provider
31
+ * config, `{}` when the provider declares no such table. */
32
+ export function runtimeProvisioningMap(parsedConfig: unknown): Record<string, unknown> {
33
+ if (parsedConfig === null || typeof parsedConfig !== 'object') return {}
34
+ const table = (parsedConfig as { runtimeProvisioning?: unknown }).runtimeProvisioning
35
+ return table !== null && typeof table === 'object' ? (table as Record<string, unknown>) : {}
36
+ }
37
+
38
+ /** Verbatim `configSchema` diagnostic for a referenced runtime with no
39
+ * provisioning entry. Shared by the config parse and the construction seam so
40
+ * the two cannot drift. */
41
+ export function runtimeProvisioningMissingMessage(group: RuntimeReferenceGroup): string {
42
+ return (
43
+ `runtime ${displayName(group.runtime)} is selected by ${group.references.join(', ')} but has no sandbox provisioning; add ` +
44
+ `[workspace.config.runtimeProvisioning.${tomlKey(group.runtime)}] with nonblank install and preflight commands`
45
+ )
46
+ }
47
+
48
+ /** Verbatim credential-free operator-sandbox diagnostic (AUT-340). Shared by
49
+ * the config parse and the construction seam so the two cannot drift. */
50
+ export function sandboxForbiddenEnvMessage(name: string): string {
51
+ return `environment variable ${JSON.stringify(name)} is a store, forge, ticket-provider, model, or Vercel credential and may never be forwarded into an operator sandbox`
52
+ }
53
+
54
+ // ── Readiness contract ───────────────────────────────────────────────────────
55
+
56
+ export interface ReadinessCheck {
57
+ name: string
58
+ status: 'pass' | 'fail' | 'absent'
59
+ detail: string
60
+ }
61
+
62
+ export interface InitValidationReport {
63
+ provider: string
64
+ context: 'local worktree' | 'Vercel Sandbox'
65
+ workspace?: string
66
+ revision?: string
67
+ checks: ReadinessCheck[]
68
+ exitCode: number
69
+ /** Vercel only: automatic snapshots deleted while releasing the disposable
70
+ * environment; zero proves no snapshot storage was left behind. */
71
+ snapshotsDeleted?: number
72
+ }
73
+
74
+ export interface GuestProbeReport {
75
+ checks: ReadinessCheck[]
76
+ }
77
+
78
+ // ── Capability declarations ──────────────────────────────────────────────────
79
+
80
+ /** One required-environment group: alternatives are tried in order, and one
81
+ * is satisfied when EVERY named variable in it is set nonempty. The group must
82
+ * carry at least one message — a group with neither `dispatchMessage` nor
83
+ * `validationMessage` is rejected at manifest parse (AUT-554), because both
84
+ * enforcement sites skip it and the declaration would never be enforced. */
85
+ export interface WorkspaceProviderEnvRequirement {
86
+ alternatives: readonly (readonly string[])[]
87
+ /** Verbatim dispatch-preflight text (`cli/dispatch.ts`). */
88
+ dispatchMessage?: string
89
+ /** Verbatim init-preflight text (`validateInitReadiness`'s hostPreflight). */
90
+ validationMessage?: string
91
+ }
92
+
93
+ /**
94
+ * Everything a workspace-provider registration can declare about its own
95
+ * behavior, so core can enforce it without naming the provider. Field texts
96
+ * are verbatim today's messages; a capability absent means "no check".
97
+ */
98
+ export interface WorkspaceProviderCapabilities {
99
+ /** Strict schema applied to `[workspace.config]`. Mutually exclusive with
100
+ * `configRefusal`. */
101
+ configSchema?: z.ZodType
102
+ /** Construction-site refusal (`createWorkspaceProvider`) for a provider
103
+ * that rejects `[workspace.config]` outright. Distinct from the parse-site
104
+ * refusal text, which lives on the builtin config table in
105
+ * `config/schema.ts` — the two strings differ by design. */
106
+ configRefusal?: string
107
+ /** Referenced runtimes must have `[workspace.config.runtimeProvisioning]`
108
+ * entries. */
109
+ requireRuntimeProvisioning?: boolean
110
+ /** Names added to the operator-sandbox forbidden-forwarding set beyond
111
+ * `SANDBOX_FORBIDDEN_ENV`. Enforced at the two registry-aware seams —
112
+ * `createWorkspaceProvider` construction and the registry-aware
113
+ * `validateInitReadiness` check — not at config parse: plugins are not
114
+ * loaded when config is parsed, and no site loads plugins earlier to widen
115
+ * parse-time coverage. The shared `SANDBOX_FORBIDDEN_ENV` names are
116
+ * rejected at parse time for every provider (AUT-536). */
117
+ sandboxForbiddenEnv?: readonly string[]
118
+ supportedForges?: readonly string[]
119
+ /** Verbatim texts; dispatch and init validation emit different sentences. */
120
+ forgeDispatchMessage?: string
121
+ forgeValidationMessage?: string
122
+ requiredEnv?: readonly WorkspaceProviderEnvRequirement[]
123
+ /** Variables that must exist in the launcher process environment, never
124
+ * only in a loaded `.env`. */
125
+ processEnvOnly?: readonly { name: string; message: string }[]
126
+ /** Store requirements for checkout-less providers, with each site's
127
+ * verbatim text. */
128
+ storeRequirements?: {
129
+ constructionMessage: string
130
+ storeRefMessage: string
131
+ storeTokenMessage: string
132
+ }
133
+ /** Validates the git origin URL before dispatch (throws the provider's own
134
+ * message) and returns the normalized origin coordinates. */
135
+ validateOrigin?: (raw: string) => { url: string; host: string; path: string; directory: string }
136
+ originReadFailureMessage?: string
137
+ /** Env names the provider forwards into the guest (readiness redaction). */
138
+ guestEnvNames?: (config: unknown) => readonly string[]
139
+ /** Readiness summary stdout lines printed after the report. The config
140
+ * argument is the `[workspace.config]` parsed with the declared
141
+ * `configSchema`, or the raw `[workspace.config]` table when no schema is
142
+ * declared — mirroring `guestEnvNames`. */
143
+ describeEnvironment?: (
144
+ config: unknown,
145
+ env: Record<string, string | undefined>,
146
+ ) => readonly string[]
147
+ /** Remote readiness validation for `ab init --validate`. */
148
+ validateReadiness?: (ctx: WorkspaceReadinessContext) => Promise<InitValidationReport>
149
+ }
150
+
151
+ /** Everything a readiness implementation reads. `facade` and `packageArchive`
152
+ * are the host's test seams, passed opaquely — the vercel implementation
153
+ * narrows them. */
154
+ export interface WorkspaceReadinessContext {
155
+ config: Config
156
+ /** The `[workspace.config]` parsed with the declared `configSchema`. */
157
+ providerConfig: unknown
158
+ env: Record<string, string | undefined>
159
+ storeRef: string
160
+ storeToken: string
161
+ repo: string
162
+ baseBranch: string
163
+ configBytes: string
164
+ exec: Exec
165
+ redact: (value: unknown) => string
166
+ runtimeReferences: readonly RuntimeReferenceGroup[]
167
+ stdout: (line: string) => void
168
+ signal?: AbortSignal
169
+ facade?: unknown
170
+ packageArchive?: () => Promise<Uint8Array>
171
+ }