@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,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
|
) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative capabilities a workspace-provider registration can supply, plus
|
|
3
|
+
* the readiness contract those capabilities speak. Core honours these
|
|
4
|
+
* declarations at registry-aware seams without naming any provider, which is
|
|
5
|
+
* what lets a third-party remote provider receive the same treatment as the
|
|
6
|
+
* builtin `vercel-sandbox` registration (AUT-516).
|
|
7
|
+
*
|
|
8
|
+
* This module is a leaf: runtime imports are limited to `config/roles`
|
|
9
|
+
* helpers, everything else is types. `config/schema.ts` imports the message
|
|
10
|
+
* builders below, and `builtin-capabilities.ts` imports `config/schema.ts` —
|
|
11
|
+
* so nothing here may import either, or module evaluation would cycle.
|
|
12
|
+
*/
|
|
13
|
+
import type { z } from 'zod'
|
|
14
|
+
import { displayName, tomlKey, type RuntimeReferenceGroup } from '../../config/roles'
|
|
15
|
+
import type { Config } from '../../config/schema'
|
|
16
|
+
import type { Exec } from './git-worktree'
|
|
17
|
+
|
|
18
|
+
/** Host-derived effective routes; consumed only by remote providers. */
|
|
19
|
+
export type RuntimeReferencesSource =
|
|
20
|
+
| readonly RuntimeReferenceGroup[]
|
|
21
|
+
| (() => readonly RuntimeReferenceGroup[])
|
|
22
|
+
|
|
23
|
+
export function currentRuntimeReferences(
|
|
24
|
+
source: RuntimeReferencesSource | undefined,
|
|
25
|
+
): readonly RuntimeReferenceGroup[] {
|
|
26
|
+
if (source === undefined) return []
|
|
27
|
+
return typeof source === 'function' ? source() : source
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `[workspace.config.runtimeProvisioning]` map of a parsed provider
|
|
31
|
+
* config, `{}` when the provider declares no such table. */
|
|
32
|
+
export function runtimeProvisioningMap(parsedConfig: unknown): Record<string, unknown> {
|
|
33
|
+
if (parsedConfig === null || typeof parsedConfig !== 'object') return {}
|
|
34
|
+
const table = (parsedConfig as { runtimeProvisioning?: unknown }).runtimeProvisioning
|
|
35
|
+
return table !== null && typeof table === 'object' ? (table as Record<string, unknown>) : {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Verbatim `configSchema` diagnostic for a referenced runtime with no
|
|
39
|
+
* provisioning entry. Shared by the config parse and the construction seam so
|
|
40
|
+
* the two cannot drift. */
|
|
41
|
+
export function runtimeProvisioningMissingMessage(group: RuntimeReferenceGroup): string {
|
|
42
|
+
return (
|
|
43
|
+
`runtime ${displayName(group.runtime)} is selected by ${group.references.join(', ')} but has no sandbox provisioning; add ` +
|
|
44
|
+
`[workspace.config.runtimeProvisioning.${tomlKey(group.runtime)}] with nonblank install and preflight commands`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Verbatim credential-free operator-sandbox diagnostic (AUT-340). Shared by
|
|
49
|
+
* the config parse and the construction seam so the two cannot drift. */
|
|
50
|
+
export function sandboxForbiddenEnvMessage(name: string): string {
|
|
51
|
+
return `environment variable ${JSON.stringify(name)} is a store, forge, ticket-provider, model, or Vercel credential and may never be forwarded into an operator sandbox`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Readiness contract ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface ReadinessCheck {
|
|
57
|
+
name: string
|
|
58
|
+
status: 'pass' | 'fail' | 'absent'
|
|
59
|
+
detail: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface InitValidationReport {
|
|
63
|
+
provider: string
|
|
64
|
+
context: 'local worktree' | 'Vercel Sandbox'
|
|
65
|
+
workspace?: string
|
|
66
|
+
revision?: string
|
|
67
|
+
checks: ReadinessCheck[]
|
|
68
|
+
exitCode: number
|
|
69
|
+
/** Vercel only: automatic snapshots deleted while releasing the disposable
|
|
70
|
+
* environment; zero proves no snapshot storage was left behind. */
|
|
71
|
+
snapshotsDeleted?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface GuestProbeReport {
|
|
75
|
+
checks: ReadinessCheck[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Capability declarations ──────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/** One required-environment group: alternatives are tried in order, and one
|
|
81
|
+
* is satisfied when EVERY named variable in it is set nonempty. The group must
|
|
82
|
+
* carry at least one message — a group with neither `dispatchMessage` nor
|
|
83
|
+
* `validationMessage` is rejected at manifest parse (AUT-554), because both
|
|
84
|
+
* enforcement sites skip it and the declaration would never be enforced. */
|
|
85
|
+
export interface WorkspaceProviderEnvRequirement {
|
|
86
|
+
alternatives: readonly (readonly string[])[]
|
|
87
|
+
/** Verbatim dispatch-preflight text (`cli/dispatch.ts`). */
|
|
88
|
+
dispatchMessage?: string
|
|
89
|
+
/** Verbatim init-preflight text (`validateInitReadiness`'s hostPreflight). */
|
|
90
|
+
validationMessage?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Everything a workspace-provider registration can declare about its own
|
|
95
|
+
* behavior, so core can enforce it without naming the provider. Field texts
|
|
96
|
+
* are verbatim today's messages; a capability absent means "no check".
|
|
97
|
+
*/
|
|
98
|
+
export interface WorkspaceProviderCapabilities {
|
|
99
|
+
/** Strict schema applied to `[workspace.config]`. Mutually exclusive with
|
|
100
|
+
* `configRefusal`. */
|
|
101
|
+
configSchema?: z.ZodType
|
|
102
|
+
/** Construction-site refusal (`createWorkspaceProvider`) for a provider
|
|
103
|
+
* that rejects `[workspace.config]` outright. Distinct from the parse-site
|
|
104
|
+
* refusal text, which lives on the builtin config table in
|
|
105
|
+
* `config/schema.ts` — the two strings differ by design. */
|
|
106
|
+
configRefusal?: string
|
|
107
|
+
/** Referenced runtimes must have `[workspace.config.runtimeProvisioning]`
|
|
108
|
+
* entries. */
|
|
109
|
+
requireRuntimeProvisioning?: boolean
|
|
110
|
+
/** Names added to the operator-sandbox forbidden-forwarding set beyond
|
|
111
|
+
* `SANDBOX_FORBIDDEN_ENV`. Enforced at the two registry-aware seams —
|
|
112
|
+
* `createWorkspaceProvider` construction and the registry-aware
|
|
113
|
+
* `validateInitReadiness` check — not at config parse: plugins are not
|
|
114
|
+
* loaded when config is parsed, and no site loads plugins earlier to widen
|
|
115
|
+
* parse-time coverage. The shared `SANDBOX_FORBIDDEN_ENV` names are
|
|
116
|
+
* rejected at parse time for every provider (AUT-536). */
|
|
117
|
+
sandboxForbiddenEnv?: readonly string[]
|
|
118
|
+
supportedForges?: readonly string[]
|
|
119
|
+
/** Verbatim texts; dispatch and init validation emit different sentences. */
|
|
120
|
+
forgeDispatchMessage?: string
|
|
121
|
+
forgeValidationMessage?: string
|
|
122
|
+
requiredEnv?: readonly WorkspaceProviderEnvRequirement[]
|
|
123
|
+
/** Variables that must exist in the launcher process environment, never
|
|
124
|
+
* only in a loaded `.env`. */
|
|
125
|
+
processEnvOnly?: readonly { name: string; message: string }[]
|
|
126
|
+
/** Store requirements for checkout-less providers, with each site's
|
|
127
|
+
* verbatim text. */
|
|
128
|
+
storeRequirements?: {
|
|
129
|
+
constructionMessage: string
|
|
130
|
+
storeRefMessage: string
|
|
131
|
+
storeTokenMessage: string
|
|
132
|
+
}
|
|
133
|
+
/** Validates the git origin URL before dispatch (throws the provider's own
|
|
134
|
+
* message) and returns the normalized origin coordinates. */
|
|
135
|
+
validateOrigin?: (raw: string) => { url: string; host: string; path: string; directory: string }
|
|
136
|
+
originReadFailureMessage?: string
|
|
137
|
+
/** Env names the provider forwards into the guest (readiness redaction). */
|
|
138
|
+
guestEnvNames?: (config: unknown) => readonly string[]
|
|
139
|
+
/** Readiness summary stdout lines printed after the report. The config
|
|
140
|
+
* argument is the `[workspace.config]` parsed with the declared
|
|
141
|
+
* `configSchema`, or the raw `[workspace.config]` table when no schema is
|
|
142
|
+
* declared — mirroring `guestEnvNames`. */
|
|
143
|
+
describeEnvironment?: (
|
|
144
|
+
config: unknown,
|
|
145
|
+
env: Record<string, string | undefined>,
|
|
146
|
+
) => readonly string[]
|
|
147
|
+
/** Remote readiness validation for `ab init --validate`. */
|
|
148
|
+
validateReadiness?: (ctx: WorkspaceReadinessContext) => Promise<InitValidationReport>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Everything a readiness implementation reads. `facade` and `packageArchive`
|
|
152
|
+
* are the host's test seams, passed opaquely — the vercel implementation
|
|
153
|
+
* narrows them. */
|
|
154
|
+
export interface WorkspaceReadinessContext {
|
|
155
|
+
config: Config
|
|
156
|
+
/** The `[workspace.config]` parsed with the declared `configSchema`. */
|
|
157
|
+
providerConfig: unknown
|
|
158
|
+
env: Record<string, string | undefined>
|
|
159
|
+
storeRef: string
|
|
160
|
+
storeToken: string
|
|
161
|
+
repo: string
|
|
162
|
+
baseBranch: string
|
|
163
|
+
configBytes: string
|
|
164
|
+
exec: Exec
|
|
165
|
+
redact: (value: unknown) => string
|
|
166
|
+
runtimeReferences: readonly RuntimeReferenceGroup[]
|
|
167
|
+
stdout: (line: string) => void
|
|
168
|
+
signal?: AbortSignal
|
|
169
|
+
facade?: unknown
|
|
170
|
+
packageArchive?: () => Promise<Uint8Array>
|
|
171
|
+
}
|