@defrex/autobuild 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -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/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- 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-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- 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 +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- 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/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- 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 +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -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 +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- 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 +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- 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/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- 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 +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- 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 +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- 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 +204 -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/skills/tickets/SKILL.md +57 -5
- 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
|
@@ -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 }
|
|
@@ -19,7 +19,12 @@ import {
|
|
|
19
19
|
type SandboxEnvironmentIdentity,
|
|
20
20
|
type SandboxWaitResult,
|
|
21
21
|
} from './operator-sandbox'
|
|
22
|
-
import type {
|
|
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))
|
|
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)
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
) {
|