@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- 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
|
|
23
|
-
* guest, store, and dispatcher versions must
|
|
24
|
-
* cut (`tools/release.ts`) plus
|
|
25
|
-
* path that safely mixes versions.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 {
|
|
31
|
-
import {
|
|
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 {
|
|
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(
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 }
|