@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,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. */
@@ -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 }