@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace adapter: git worktrees (SPEC §3.2). The worktree is disposable
|
|
3
|
+
* scratch (§7); the branch is the build's durable code and travels through
|
|
4
|
+
* the Forge, never the store (§15.2.4 [D3]) — so `release` removes only the
|
|
5
|
+
* worktree and never touches the branch. Idempotent `provision` is what makes
|
|
6
|
+
* resume-after-sandbox-death (§15.6-C) a re-run, not a special path
|
|
7
|
+
* (constitution #2).
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, realpath } from 'node:fs/promises'
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
11
|
+
import { readFile as fsReadFile, rm, stat, writeFile as fsWriteFile } from 'node:fs/promises'
|
|
12
|
+
import { join, resolve, sep } from 'node:path'
|
|
13
|
+
import type { WorkspaceBase } from '../../ontology'
|
|
14
|
+
import {
|
|
15
|
+
SandboxOperationError,
|
|
16
|
+
type OperatorSandboxExecution,
|
|
17
|
+
type SandboxCommandRequest,
|
|
18
|
+
type SandboxCommandResult,
|
|
19
|
+
type SandboxEnvironmentIdentity,
|
|
20
|
+
type SandboxWaitResult,
|
|
21
|
+
} from './operator-sandbox'
|
|
22
|
+
import type { WorkspaceHandle, WorkspaceProvider, WorkspaceProvisionResult } from '../types'
|
|
23
|
+
|
|
24
|
+
export interface ExecResult {
|
|
25
|
+
stdout: string
|
|
26
|
+
stderr: string
|
|
27
|
+
exitCode: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Injectable exec seam — the default shells out via Bun.spawn. */
|
|
31
|
+
export type Exec = (
|
|
32
|
+
cmd: string[],
|
|
33
|
+
opts: { cwd?: string; signal?: AbortSignal },
|
|
34
|
+
) => Promise<ExecResult>
|
|
35
|
+
|
|
36
|
+
export const spawnExec: Exec = async (cmd, opts) => {
|
|
37
|
+
const proc = Bun.spawn(cmd, {
|
|
38
|
+
cwd: opts.cwd,
|
|
39
|
+
stdin: 'ignore',
|
|
40
|
+
stdout: 'pipe',
|
|
41
|
+
stderr: 'pipe',
|
|
42
|
+
...(opts.signal === undefined ? {} : { signal: opts.signal }),
|
|
43
|
+
})
|
|
44
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
45
|
+
new Response(proc.stdout).text(),
|
|
46
|
+
new Response(proc.stderr).text(),
|
|
47
|
+
proc.exited,
|
|
48
|
+
])
|
|
49
|
+
return { stdout, stderr, exitCode }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class GitError extends Error {
|
|
53
|
+
constructor(
|
|
54
|
+
readonly args: string[],
|
|
55
|
+
readonly result: ExecResult,
|
|
56
|
+
) {
|
|
57
|
+
super(
|
|
58
|
+
`git ${args.join(' ')} exited ${result.exitCode}: ${
|
|
59
|
+
result.stderr.trim() || result.stdout.trim() || '(no output)'
|
|
60
|
+
}`,
|
|
61
|
+
)
|
|
62
|
+
this.name = 'GitError'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `ab/build-123` → `ab-build-123` — one subdir per branch under the root. */
|
|
67
|
+
function sanitizeBranch(branch: string): string {
|
|
68
|
+
const cleaned = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '')
|
|
69
|
+
return cleaned || 'branch'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface WorktreeEntry {
|
|
73
|
+
path: string
|
|
74
|
+
head?: string
|
|
75
|
+
branch?: string
|
|
76
|
+
detached: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface WorktreeSelection {
|
|
80
|
+
/** Requested branch attached inside this provider's root. */
|
|
81
|
+
attached?: WorktreeEntry
|
|
82
|
+
/** Detached registration at the requested branch's deterministic path. */
|
|
83
|
+
detached?: WorktreeEntry
|
|
84
|
+
/** The requested branch is attached anywhere, including outside our root. */
|
|
85
|
+
branchAttached: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseWorktreeList(porcelain: string): WorktreeEntry[] {
|
|
89
|
+
const entries: WorktreeEntry[] = []
|
|
90
|
+
for (const block of porcelain.split('\n\n')) {
|
|
91
|
+
let path: string | undefined
|
|
92
|
+
let head: string | undefined
|
|
93
|
+
let branch: string | undefined
|
|
94
|
+
let detached = false
|
|
95
|
+
for (const line of block.split('\n')) {
|
|
96
|
+
if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
|
|
97
|
+
else if (line.startsWith('HEAD ')) head = line.slice('HEAD '.length)
|
|
98
|
+
else if (line.startsWith('branch refs/heads/'))
|
|
99
|
+
branch = line.slice('branch refs/heads/'.length)
|
|
100
|
+
else if (line === 'detached') detached = true
|
|
101
|
+
}
|
|
102
|
+
if (path) entries.push({ path, head, branch, detached })
|
|
103
|
+
}
|
|
104
|
+
return entries
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class GitWorktreeProvider implements WorkspaceProvider {
|
|
108
|
+
readonly name = 'git-worktree'
|
|
109
|
+
readonly orchestratorSandbox: OperatorSandboxExecution
|
|
110
|
+
private readonly root: string
|
|
111
|
+
private readonly sandboxRoot: string
|
|
112
|
+
private readonly setupCommand: string | undefined
|
|
113
|
+
private readonly sandboxEnvironmentVariables: readonly string[]
|
|
114
|
+
private readonly sandboxEnvSource: Record<string, string | undefined>
|
|
115
|
+
/** Canonicalized (symlink-free) root, resolved lazily — git reports
|
|
116
|
+
* worktree paths canonicalized, so comparisons must be too. */
|
|
117
|
+
private rootReal: string | null = null
|
|
118
|
+
private readonly exec: Exec
|
|
119
|
+
/** worktree path → repo it was provisioned from, so `release` can find the
|
|
120
|
+
* repo without re-deriving it. Rebuilt lazily after a restart (release of a
|
|
121
|
+
* still-on-disk worktree rediscovers the repo via git-common-dir). */
|
|
122
|
+
private readonly repos = new Map<string, string>()
|
|
123
|
+
|
|
124
|
+
constructor(opts: {
|
|
125
|
+
root: string
|
|
126
|
+
exec?: Exec
|
|
127
|
+
/** Operator-sandbox worktree root; defaults to a sibling of the build
|
|
128
|
+
* worktree root inside the local state tree. */
|
|
129
|
+
sandboxRoot?: string
|
|
130
|
+
/** The repository's raw `[commands].setup` shell string, run once per
|
|
131
|
+
* fresh operator-sandbox worktree. */
|
|
132
|
+
setupCommand?: string
|
|
133
|
+
/** Forwarded non-secret variable names, resolved from `envSource`. */
|
|
134
|
+
sandboxEnvironmentVariables?: readonly string[]
|
|
135
|
+
/** Host environment the guest PATH and forwarded names resolve from.
|
|
136
|
+
* The guest environment is otherwise built from an empty record. */
|
|
137
|
+
envSource?: Record<string, string | undefined>
|
|
138
|
+
}) {
|
|
139
|
+
this.root = resolve(opts.root)
|
|
140
|
+
this.sandboxRoot = resolve(opts.sandboxRoot ?? join(opts.root, '..', 'orchestrator-sandboxes'))
|
|
141
|
+
this.setupCommand = opts.setupCommand
|
|
142
|
+
this.sandboxEnvironmentVariables = opts.sandboxEnvironmentVariables ?? []
|
|
143
|
+
this.sandboxEnvSource = opts.envSource ?? {}
|
|
144
|
+
this.exec = opts.exec ?? spawnExec
|
|
145
|
+
this.orchestratorSandbox = {
|
|
146
|
+
describe: (input) => Promise.resolve(this.sandboxIdentity(input.repo, input.operator)),
|
|
147
|
+
ensure: (input) => this.ensureSandbox(input),
|
|
148
|
+
exec: (handle, request) => this.sandboxExec(handle, request),
|
|
149
|
+
start: (handle, request) => this.sandboxStart(handle, request),
|
|
150
|
+
wait: (handle, input) => this.sandboxWait(handle, input),
|
|
151
|
+
readFile: (handle, path) => this.sandboxReadFile(handle, path),
|
|
152
|
+
writeFile: (handle, path, content) => this.sandboxWriteFile(handle, path, content),
|
|
153
|
+
stop: () => Promise.resolve({ outcome: 'unsupported' as const }),
|
|
154
|
+
release: (input) => this.sandboxRelease(input),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async realRoot(): Promise<string> {
|
|
159
|
+
if (!this.rootReal) {
|
|
160
|
+
await mkdir(this.root, { recursive: true })
|
|
161
|
+
this.rootReal = await realpath(this.root)
|
|
162
|
+
}
|
|
163
|
+
return this.rootReal
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Runs git against `dir` via `-C` so a nonexistent dir surfaces as git's
|
|
167
|
+
* own stderr (`cannot change to …`) rather than a spawn failure. */
|
|
168
|
+
private async git(dir: string, args: string[]): Promise<ExecResult> {
|
|
169
|
+
return this.exec(['git', '-C', dir, ...args], {})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async gitOrThrow(dir: string, args: string[]): Promise<ExecResult> {
|
|
173
|
+
const result = await this.git(dir, args)
|
|
174
|
+
if (result.exitCode !== 0) throw new GitError(['-C', dir, ...args], result)
|
|
175
|
+
return result
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private shaFrom(dir: string, args: string[], result: ExecResult): string {
|
|
179
|
+
if (result.exitCode !== 0) {
|
|
180
|
+
throw new GitError(['-C', dir, ...args], result)
|
|
181
|
+
}
|
|
182
|
+
const sha = result.stdout.trim()
|
|
183
|
+
if (sha === '') {
|
|
184
|
+
throw new GitError(['-C', dir, ...args], {
|
|
185
|
+
...result,
|
|
186
|
+
exitCode: 1,
|
|
187
|
+
stderr: 'git returned no commit SHA',
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
return sha
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async resolveCommit(repo: string, ref: string): Promise<string> {
|
|
194
|
+
const args = ['rev-parse', '--verify', `${ref}^{commit}`]
|
|
195
|
+
return this.shaFrom(repo, args, await this.git(repo, args))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async resolveOptionalCommit(repo: string, ref: string): Promise<string | null> {
|
|
199
|
+
const args = ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]
|
|
200
|
+
const result = await this.git(repo, args)
|
|
201
|
+
if (result.exitCode === 0) return this.shaFrom(repo, args, result)
|
|
202
|
+
if (result.exitCode === 1) return null
|
|
203
|
+
throw new GitError(['-C', repo, ...args], result)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async isAncestor(repo: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
207
|
+
const args = ['merge-base', '--is-ancestor', ancestor, descendant]
|
|
208
|
+
const result = await this.git(repo, args)
|
|
209
|
+
if (result.exitCode === 0) return true
|
|
210
|
+
if (result.exitCode === 1) return false
|
|
211
|
+
throw new GitError(['-C', repo, ...args], result)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reconcile a stale local build ref from the ref updated by a successful
|
|
215
|
+
* Forge push. Both ancestry checks and the expected-old update make this a
|
|
216
|
+
* normal fast-forward that cannot overwrite divergent or concurrent work. */
|
|
217
|
+
private async recoverPublishedBranch(
|
|
218
|
+
repo: string,
|
|
219
|
+
branch: string,
|
|
220
|
+
localSha: string,
|
|
221
|
+
): Promise<string> {
|
|
222
|
+
const branchRef = `refs/heads/${branch}`
|
|
223
|
+
const publishedRef = `refs/remotes/origin/${branch}`
|
|
224
|
+
const publishedSha = await this.resolveOptionalCommit(repo, publishedRef)
|
|
225
|
+
if (!publishedSha || publishedSha === localSha) return localSha
|
|
226
|
+
|
|
227
|
+
if (await this.isAncestor(repo, localSha, publishedSha)) {
|
|
228
|
+
await this.gitOrThrow(repo, ['update-ref', branchRef, publishedSha, localSha])
|
|
229
|
+
return publishedSha
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (await this.isAncestor(repo, publishedSha, localSha)) return localSha
|
|
233
|
+
|
|
234
|
+
throw new Error(
|
|
235
|
+
`cannot recover ${branchRef}: local tip ${localSha} and published ` +
|
|
236
|
+
`${publishedRef} tip ${publishedSha} have diverged; refusing to rewrite either ref`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Attach the one detached registration this provider owns. The published
|
|
241
|
+
* ref must first have made its HEAD exactly the durable branch tip; an
|
|
242
|
+
* arbitrary detached commit is never adopted or discarded. */
|
|
243
|
+
private async reattachDetachedWorktree(
|
|
244
|
+
repo: string,
|
|
245
|
+
entry: WorktreeEntry,
|
|
246
|
+
branch: string,
|
|
247
|
+
expectedSha: string,
|
|
248
|
+
): Promise<string> {
|
|
249
|
+
if (!entry.head) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`cannot recover detached worktree ${entry.path}: its registered HEAD is missing`,
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const liveHead = await this.resolveCommit(entry.path, 'HEAD')
|
|
256
|
+
const branchRef = `refs/heads/${branch}`
|
|
257
|
+
const branchSha = await this.resolveCommit(repo, branchRef)
|
|
258
|
+
if (entry.head !== liveHead || branchSha !== expectedSha) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`cannot recover detached worktree ${entry.path}: its registration or ` +
|
|
261
|
+
`${branchRef} changed during provisioning; refusing to move it`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
if (liveHead !== branchSha) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`cannot recover detached worktree ${entry.path}: detached HEAD ${liveHead} ` +
|
|
267
|
+
`does not match durable ${branchRef} tip ${branchSha}; refusing to discard either commit`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await this.gitOrThrow(entry.path, ['checkout', '--quiet', branch])
|
|
272
|
+
|
|
273
|
+
const symbolicArgs = ['symbolic-ref', '--quiet', '--short', 'HEAD']
|
|
274
|
+
const symbolic = await this.git(entry.path, symbolicArgs)
|
|
275
|
+
const actualHead = await this.resolveCommit(entry.path, 'HEAD')
|
|
276
|
+
const actualBranch = await this.resolveCommit(repo, branchRef)
|
|
277
|
+
if (
|
|
278
|
+
symbolic.exitCode !== 0 ||
|
|
279
|
+
symbolic.stdout.trim() !== branch ||
|
|
280
|
+
actualHead !== expectedSha ||
|
|
281
|
+
actualBranch !== expectedSha
|
|
282
|
+
) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`detached worktree recovery at ${entry.path} did not produce ${branch} ` +
|
|
285
|
+
`at expected commit ${expectedSha}`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
return actualHead
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Select a new branch's base without mutating any operator or shared
|
|
292
|
+
* remote-tracking ref. A remote failure is evidence, not a dispatch gate. */
|
|
293
|
+
private async selectNewBranchBase(
|
|
294
|
+
repo: string,
|
|
295
|
+
baseBranch: string,
|
|
296
|
+
branch: string,
|
|
297
|
+
): Promise<WorkspaceBase> {
|
|
298
|
+
const fetchedRef = `refs/autobuild/provision/${branch}/base`
|
|
299
|
+
const fetchArgs = [
|
|
300
|
+
'fetch',
|
|
301
|
+
'--no-tags',
|
|
302
|
+
'--no-write-fetch-head',
|
|
303
|
+
'--refmap=',
|
|
304
|
+
'origin',
|
|
305
|
+
`+refs/heads/${baseBranch}:${fetchedRef}`,
|
|
306
|
+
]
|
|
307
|
+
const fetched = await this.git(repo, fetchArgs)
|
|
308
|
+
|
|
309
|
+
let remoteError: string | undefined
|
|
310
|
+
if (fetched.exitCode !== 0) {
|
|
311
|
+
remoteError = new GitError(['-C', repo, ...fetchArgs], fetched).message
|
|
312
|
+
} else {
|
|
313
|
+
const resolveArgs = ['rev-parse', '--verify', `${fetchedRef}^{commit}`]
|
|
314
|
+
const resolved = await this.git(repo, resolveArgs)
|
|
315
|
+
if (resolved.exitCode === 0) {
|
|
316
|
+
return { source: 'remote', sha: this.shaFrom(repo, resolveArgs, resolved) }
|
|
317
|
+
}
|
|
318
|
+
remoteError = new GitError(['-C', repo, ...resolveArgs], resolved).message
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Remote refresh failures are non-fatal only while the fully qualified
|
|
322
|
+
// local base is still a usable commit. A missing local base remains a
|
|
323
|
+
// provisioning error rather than being hidden behind the fallback.
|
|
324
|
+
const sha = await this.resolveCommit(repo, `refs/heads/${baseBranch}`)
|
|
325
|
+
return { source: 'local', sha, remoteError }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async provision(opts: {
|
|
329
|
+
repo: string
|
|
330
|
+
baseBranch: string
|
|
331
|
+
branch: string
|
|
332
|
+
}): Promise<WorkspaceProvisionResult> {
|
|
333
|
+
const { repo, baseBranch, branch } = opts
|
|
334
|
+
// Fails with git's stderr for a missing path or a non-repo directory.
|
|
335
|
+
await this.gitOrThrow(repo, ['rev-parse', '--git-dir'])
|
|
336
|
+
// Drop registrations whose directories were deleted out-of-band, so
|
|
337
|
+
// re-provision after a hand-cleaned sandbox still succeeds.
|
|
338
|
+
await this.gitOrThrow(repo, ['worktree', 'prune'])
|
|
339
|
+
|
|
340
|
+
const rootDir = await this.realRoot()
|
|
341
|
+
const worktreePath = join(rootDir, sanitizeBranch(branch))
|
|
342
|
+
const worktrees = await this.findWorktrees(repo, branch, rootDir, worktreePath)
|
|
343
|
+
if (worktrees.attached) {
|
|
344
|
+
// Idempotent provision (constitution #2): the registered worktree — at
|
|
345
|
+
// the branch's current tip — is the resume point, not a fresh checkout.
|
|
346
|
+
const sha = await this.resolveCommit(repo, `refs/heads/${branch}`)
|
|
347
|
+
this.repos.set(worktrees.attached.path, repo)
|
|
348
|
+
return {
|
|
349
|
+
provider: this.name,
|
|
350
|
+
ref: worktrees.attached.path,
|
|
351
|
+
path: worktrees.attached.path,
|
|
352
|
+
localPath: worktrees.attached.path,
|
|
353
|
+
branch,
|
|
354
|
+
base: { source: 'existing', sha },
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const branchRef = `refs/heads/${branch}`
|
|
359
|
+
const localSha = await this.resolveOptionalCommit(repo, branchRef)
|
|
360
|
+
if (localSha) {
|
|
361
|
+
// A branch attached outside this provider's root is not ours to repair
|
|
362
|
+
// or reuse. The ordinary worktree-add/checkout error below remains the
|
|
363
|
+
// non-destructive diagnostic for that unsupported registration.
|
|
364
|
+
const sha = worktrees.branchAttached
|
|
365
|
+
? localSha
|
|
366
|
+
: await this.recoverPublishedBranch(repo, branch, localSha)
|
|
367
|
+
|
|
368
|
+
if (worktrees.detached) {
|
|
369
|
+
if (worktrees.branchAttached) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`cannot recover detached worktree ${worktrees.detached.path}: ` +
|
|
372
|
+
`${branchRef} is already attached to another worktree`,
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
const recoveredSha = await this.reattachDetachedWorktree(
|
|
376
|
+
repo,
|
|
377
|
+
worktrees.detached,
|
|
378
|
+
branch,
|
|
379
|
+
sha,
|
|
380
|
+
)
|
|
381
|
+
this.repos.set(worktrees.detached.path, repo)
|
|
382
|
+
return {
|
|
383
|
+
provider: this.name,
|
|
384
|
+
ref: worktrees.detached.path,
|
|
385
|
+
path: worktrees.detached.path,
|
|
386
|
+
localPath: worktrees.detached.path,
|
|
387
|
+
branch,
|
|
388
|
+
base: { source: 'existing', sha: recoveredSha },
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Resume (§15.6-C): rematerialize only after the local branch has been
|
|
393
|
+
// monotonically reconciled with any push-updated publication evidence.
|
|
394
|
+
// This path performs no fetch and no remote write.
|
|
395
|
+
await this.gitOrThrow(repo, ['worktree', 'add', worktreePath, branch])
|
|
396
|
+
this.repos.set(worktreePath, repo)
|
|
397
|
+
return {
|
|
398
|
+
provider: this.name,
|
|
399
|
+
ref: worktreePath,
|
|
400
|
+
path: worktreePath,
|
|
401
|
+
localPath: worktreePath,
|
|
402
|
+
branch,
|
|
403
|
+
base: { source: 'existing', sha },
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (worktrees.detached) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`cannot recover detached worktree ${worktrees.detached.path}: ` +
|
|
410
|
+
`${branchRef} does not exist; refusing to adopt an arbitrary detached commit`,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const base = await this.selectNewBranchBase(repo, baseBranch, branch)
|
|
415
|
+
// Consume the selected immutable commit, not a mutable name that could
|
|
416
|
+
// move between fetch and branch creation.
|
|
417
|
+
await this.gitOrThrow(repo, ['worktree', 'add', '-b', branch, worktreePath, base.sha])
|
|
418
|
+
const actualSha = await this.resolveCommit(repo, branchRef)
|
|
419
|
+
if (actualSha !== base.sha) {
|
|
420
|
+
throw new Error(`created ${branchRef} at ${actualSha}, expected selected base ${base.sha}`)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
this.repos.set(worktreePath, repo)
|
|
424
|
+
return {
|
|
425
|
+
provider: this.name,
|
|
426
|
+
ref: worktreePath,
|
|
427
|
+
path: worktreePath,
|
|
428
|
+
localPath: worktreePath,
|
|
429
|
+
branch,
|
|
430
|
+
base: { ...base, sha: actualSha },
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async release(handle: WorkspaceHandle): Promise<void> {
|
|
435
|
+
const path = resolve(handle.ref)
|
|
436
|
+
const repo = this.repos.get(path) ?? (await this.discoverRepo(path))
|
|
437
|
+
// No repo to prune against means nothing was ever provisioned here (or it
|
|
438
|
+
// is already fully gone) — releasing it is a no-op, not an error.
|
|
439
|
+
if (!repo) return
|
|
440
|
+
const removed = await this.git(repo, ['worktree', 'remove', '--force', path])
|
|
441
|
+
// Already-gone worktrees are a no-op: unregistered paths say "is not a
|
|
442
|
+
// working tree"; registered-but-deleted dirs say "validation failed …"
|
|
443
|
+
// on older gits (2.50 removes them silently). Prune below cleans up.
|
|
444
|
+
if (
|
|
445
|
+
removed.exitCode !== 0 &&
|
|
446
|
+
!/is not a working tree|validation failed, cannot remove working tree/i.test(removed.stderr)
|
|
447
|
+
) {
|
|
448
|
+
throw new GitError(['-C', repo, 'worktree', 'remove', '--force', path], removed)
|
|
449
|
+
}
|
|
450
|
+
await this.gitOrThrow(repo, ['worktree', 'prune'])
|
|
451
|
+
this.repos.delete(path)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Discover only registrations relevant to the requested branch. Attached
|
|
455
|
+
* reuse stays root-scoped (and therefore excludes the main checkout); a
|
|
456
|
+
* detached registration is recoverable only at this branch's exact,
|
|
457
|
+
* deterministic provider-owned path. */
|
|
458
|
+
private async findWorktrees(
|
|
459
|
+
repo: string,
|
|
460
|
+
branch: string,
|
|
461
|
+
rootDir: string,
|
|
462
|
+
worktreePath: string,
|
|
463
|
+
): Promise<WorktreeSelection> {
|
|
464
|
+
const list = await this.gitOrThrow(repo, ['worktree', 'list', '--porcelain'])
|
|
465
|
+
const entries = parseWorktreeList(list.stdout).map((entry) => ({
|
|
466
|
+
...entry,
|
|
467
|
+
path: resolve(entry.path),
|
|
468
|
+
}))
|
|
469
|
+
return {
|
|
470
|
+
attached: entries.find(
|
|
471
|
+
(entry) => entry.branch === branch && entry.path.startsWith(rootDir + sep),
|
|
472
|
+
),
|
|
473
|
+
detached: entries.find((entry) => entry.detached && entry.path === worktreePath),
|
|
474
|
+
branchAttached: entries.some((entry) => entry.branch === branch),
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Main-repo directory owning the worktree at `path`, or null if the path
|
|
479
|
+
* 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). */
|
|
481
|
+
private async discoverRepo(path: string): Promise<string | null> {
|
|
482
|
+
const result = await this.git(path, ['rev-parse', '--git-common-dir'])
|
|
483
|
+
if (result.exitCode !== 0) return null
|
|
484
|
+
const commonDir = resolve(path, result.stdout.trim())
|
|
485
|
+
// The common dir is the main repo's `.git`; worktree commands need the
|
|
486
|
+
// repo directory itself (a bare common dir already is one).
|
|
487
|
+
return commonDir.endsWith(`${sep}.git`) ? commonDir.slice(0, -`${sep}.git`.length) : commonDir
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── Operator sandbox capability (AUT-340) ──────────────────────
|
|
491
|
+
//
|
|
492
|
+
// One worktree per operator × repository under the local state tree,
|
|
493
|
+
// provisioned with the repository's setup command; the same registry tools
|
|
494
|
+
// operate on it. A local worktree has no stop/snapshot lifecycle, so `stop`
|
|
495
|
+
// declines honestly and the idle settlement skips it.
|
|
496
|
+
|
|
497
|
+
private sandboxPath(repo: string, operator: string): string {
|
|
498
|
+
const digest = createHash('sha256').update(`${repo}\0${operator}`).digest('hex').slice(0, 10)
|
|
499
|
+
return join(this.sandboxRoot, digest)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private sandboxIdentity(repo: string, operator: string): SandboxEnvironmentIdentity {
|
|
503
|
+
const path = this.sandboxPath(repo, operator)
|
|
504
|
+
return { provider: this.name, environmentId: path, workspacePath: path }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The credential-free guest environment: one fresh record per call —
|
|
508
|
+
* never the host environment wholesale — holding the host PATH plus the
|
|
509
|
+
* forwarded names. */
|
|
510
|
+
private sandboxEnv(): Record<string, string> {
|
|
511
|
+
const env: Record<string, string> = {
|
|
512
|
+
PATH: this.sandboxEnvSource.PATH ?? '/usr/local/bin:/usr/bin:/bin',
|
|
513
|
+
}
|
|
514
|
+
for (const name of this.sandboxEnvironmentVariables) {
|
|
515
|
+
const value = this.sandboxEnvSource[name]
|
|
516
|
+
if (value === undefined || value === '') {
|
|
517
|
+
throw new SandboxOperationError(
|
|
518
|
+
'environment',
|
|
519
|
+
`operator sandbox environment variable ${name} is not set in the host environment`,
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
env[name] = value
|
|
523
|
+
}
|
|
524
|
+
return env
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private async isRegisteredWorktree(path: string, repo: string): Promise<boolean> {
|
|
528
|
+
const list = await this.git(repo, ['worktree', 'list', '--porcelain'])
|
|
529
|
+
if (list.exitCode !== 0) return false
|
|
530
|
+
return parseWorktreeList(list.stdout).some((entry) => resolve(entry.path) === resolve(path))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async ensureSandbox(input: {
|
|
534
|
+
repo: string
|
|
535
|
+
operator: string
|
|
536
|
+
baseBranch: string
|
|
537
|
+
}): Promise<SandboxEnvironmentIdentity> {
|
|
538
|
+
const identity = this.sandboxIdentity(input.repo, input.operator)
|
|
539
|
+
if (await this.isRegisteredWorktree(identity.environmentId, input.repo)) return identity
|
|
540
|
+
await mkdir(this.sandboxRoot, { recursive: true })
|
|
541
|
+
const baseHead = await this.resolveCommit(input.repo, `refs/heads/${input.baseBranch}`)
|
|
542
|
+
await this.gitOrThrow(input.repo, [
|
|
543
|
+
'worktree',
|
|
544
|
+
'add',
|
|
545
|
+
'--detach',
|
|
546
|
+
identity.environmentId,
|
|
547
|
+
baseHead,
|
|
548
|
+
])
|
|
549
|
+
try {
|
|
550
|
+
const marker = join(identity.workspacePath, '.autobuild-sandbox-provisioned')
|
|
551
|
+
const provisioned = await stat(marker).then(
|
|
552
|
+
() => true,
|
|
553
|
+
() => false,
|
|
554
|
+
)
|
|
555
|
+
if (!provisioned && this.setupCommand !== undefined && this.setupCommand.trim() !== '') {
|
|
556
|
+
// The setup command runs with the same credential-free guest
|
|
557
|
+
// environment as every tool exec — never the host environment
|
|
558
|
+
// wholesale, or a setup script would be a second route to build
|
|
559
|
+
// state. Unbounded: provisioning may install for a long time.
|
|
560
|
+
const result = await this.spawnSandboxCommand(this.setupCommand, identity.workspacePath)
|
|
561
|
+
if (result.exitCode !== 0) {
|
|
562
|
+
throw new SandboxOperationError(
|
|
563
|
+
'provision',
|
|
564
|
+
`operator sandbox setup failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`,
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
await fsWriteFile(marker, '')
|
|
569
|
+
} catch (error) {
|
|
570
|
+
// A half-provisioned worktree is removed so the next call starts over.
|
|
571
|
+
await this.git(input.repo, ['worktree', 'remove', '--force', identity.environmentId])
|
|
572
|
+
await this.git(input.repo, ['worktree', 'prune'])
|
|
573
|
+
throw error
|
|
574
|
+
}
|
|
575
|
+
return identity
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** One `sh -c` command inside the sandbox worktree with the credential-free
|
|
579
|
+
* guest environment (an empty record plus the host PATH and the forwarded
|
|
580
|
+
* names — never the host environment wholesale). `timeoutSeconds` bounds
|
|
581
|
+
* the child and kills it past the bound; undefined runs unbounded (the
|
|
582
|
+
* setup command may install for a long time). */
|
|
583
|
+
private async spawnSandboxCommand(
|
|
584
|
+
command: string,
|
|
585
|
+
cwd: string,
|
|
586
|
+
timeoutSeconds?: number,
|
|
587
|
+
): Promise<SandboxCommandResult> {
|
|
588
|
+
const proc = Bun.spawn(['sh', '-c', command], {
|
|
589
|
+
cwd,
|
|
590
|
+
stdin: 'ignore',
|
|
591
|
+
stdout: 'pipe',
|
|
592
|
+
stderr: 'pipe',
|
|
593
|
+
env: this.sandboxEnv(),
|
|
594
|
+
})
|
|
595
|
+
if (timeoutSeconds === undefined) {
|
|
596
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
597
|
+
new Response(proc.stdout).text(),
|
|
598
|
+
new Response(proc.stderr).text(),
|
|
599
|
+
proc.exited,
|
|
600
|
+
])
|
|
601
|
+
return { exitCode: exitCode ?? -1, stdout, stderr }
|
|
602
|
+
}
|
|
603
|
+
const timer = AbortSignal.timeout(timeoutSeconds * 1000)
|
|
604
|
+
const exitedOrTimeout = Promise.race([
|
|
605
|
+
proc.exited.then((code) => ({ kind: 'exited' as const, code })),
|
|
606
|
+
new Promise<never>((_, reject) => {
|
|
607
|
+
timer.addEventListener(
|
|
608
|
+
'abort',
|
|
609
|
+
() =>
|
|
610
|
+
reject(
|
|
611
|
+
new SandboxOperationError(
|
|
612
|
+
'exec-timeout',
|
|
613
|
+
`sandbox command exceeded its ${timeoutSeconds}s bound and was killed`,
|
|
614
|
+
),
|
|
615
|
+
),
|
|
616
|
+
{ once: true },
|
|
617
|
+
)
|
|
618
|
+
}),
|
|
619
|
+
])
|
|
620
|
+
const outcome = await exitedOrTimeout.then(
|
|
621
|
+
(value) => value,
|
|
622
|
+
(error) => {
|
|
623
|
+
try {
|
|
624
|
+
proc.kill('SIGTERM')
|
|
625
|
+
} catch {
|
|
626
|
+
/* already exited */
|
|
627
|
+
}
|
|
628
|
+
throw error
|
|
629
|
+
},
|
|
630
|
+
)
|
|
631
|
+
const [stdout, stderr] = await Promise.all([
|
|
632
|
+
new Response(proc.stdout).text(),
|
|
633
|
+
new Response(proc.stderr).text(),
|
|
634
|
+
])
|
|
635
|
+
return { exitCode: outcome.code ?? -1, stdout, stderr }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private async sandboxExec(
|
|
639
|
+
handle: SandboxEnvironmentIdentity,
|
|
640
|
+
request: SandboxCommandRequest,
|
|
641
|
+
): Promise<SandboxCommandResult> {
|
|
642
|
+
const timeoutSeconds = Math.min(Math.max(request.timeoutSeconds ?? 120, 1), 300)
|
|
643
|
+
const cwd =
|
|
644
|
+
request.cwd === undefined ? handle.workspacePath : join(handle.workspacePath, request.cwd)
|
|
645
|
+
return this.spawnSandboxCommand(request.command, cwd, timeoutSeconds)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Bound on retained finished detached commands. A finished entry whose
|
|
649
|
+
* result was delivered (`wait` observed `exited`) is consumed immediately;
|
|
650
|
+
* this cap covers the fire-and-forget case — commands that finish but are
|
|
651
|
+
* never waited on — so the map (and its retained stdout/stderr strings)
|
|
652
|
+
* stays bounded at ~cap entries' worth of output regardless of how many
|
|
653
|
+
* commands the process ever runs. Running entries are never evicted. */
|
|
654
|
+
private static readonly MAX_RETAINED_SANDBOX_COMMANDS = 50
|
|
655
|
+
|
|
656
|
+
/** Detached children tracked in-process only; a restarted host reports a
|
|
657
|
+
* recorded command as gone (`wait` fails typed `not-found`). Eviction uses
|
|
658
|
+
* the same semantics: the first `wait` that observes `exited` consumes the
|
|
659
|
+
* entry (a later `wait` on the same id fails typed `not-found`, exactly as
|
|
660
|
+
* after a restart), and finished-but-never-waited entries are bounded by
|
|
661
|
+
* `MAX_RETAINED_SANDBOX_COMMANDS`. */
|
|
662
|
+
private readonly sandboxChildren = new Map<
|
|
663
|
+
string,
|
|
664
|
+
{
|
|
665
|
+
proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
|
|
666
|
+
stdout: string
|
|
667
|
+
stderr: string
|
|
668
|
+
exitCode: number | null
|
|
669
|
+
}
|
|
670
|
+
>()
|
|
671
|
+
|
|
672
|
+
/** Append a chunk to an entry's captured stream, tolerating an entry that
|
|
673
|
+
* was already evicted (consumed by `wait` or removed by the cap): late
|
|
674
|
+
* chunks for a delivered command are dropped, never a crash. */
|
|
675
|
+
private appendChildStream(commandId: string, stream: 'stdout' | 'stderr', chunk: string): void {
|
|
676
|
+
const tracked = this.sandboxChildren.get(commandId)
|
|
677
|
+
if (tracked === undefined) return
|
|
678
|
+
tracked[stream] += chunk
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** While the map exceeds the retention cap, delete the oldest finished
|
|
682
|
+
* entries in insertion order (`Map` iterates in insertion order). Running
|
|
683
|
+
* entries are never evicted — the live procs themselves are the resource. */
|
|
684
|
+
private evictFinishedBeyondCap(): void {
|
|
685
|
+
if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) return
|
|
686
|
+
for (const [id, tracked] of this.sandboxChildren) {
|
|
687
|
+
if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) break
|
|
688
|
+
if (tracked.exitCode !== null) this.sandboxChildren.delete(id)
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private trackChildStreams(
|
|
693
|
+
commandId: string,
|
|
694
|
+
child: { proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'> },
|
|
695
|
+
): void {
|
|
696
|
+
void (async () => {
|
|
697
|
+
const reader = child.proc.stdout.getReader()
|
|
698
|
+
const decoder = new TextDecoder()
|
|
699
|
+
for (;;) {
|
|
700
|
+
const { done, value } = await reader.read()
|
|
701
|
+
if (done) break
|
|
702
|
+
this.appendChildStream(commandId, 'stdout', decoder.decode(value, { stream: true }))
|
|
703
|
+
}
|
|
704
|
+
})()
|
|
705
|
+
void (async () => {
|
|
706
|
+
const reader = child.proc.stderr.getReader()
|
|
707
|
+
const decoder = new TextDecoder()
|
|
708
|
+
for (;;) {
|
|
709
|
+
const { done, value } = await reader.read()
|
|
710
|
+
if (done) break
|
|
711
|
+
this.appendChildStream(commandId, 'stderr', decoder.decode(value, { stream: true }))
|
|
712
|
+
}
|
|
713
|
+
})()
|
|
714
|
+
void child.proc.exited.then((code) => {
|
|
715
|
+
const tracked = this.sandboxChildren.get(commandId)
|
|
716
|
+
if (tracked === undefined) return
|
|
717
|
+
tracked.exitCode = code ?? -1
|
|
718
|
+
// Enforce the cap on the stamp path too: a burst of fire-and-forget
|
|
719
|
+
// commands whose exits land after the last start's eviction pass must
|
|
720
|
+
// still settle within bound.
|
|
721
|
+
this.evictFinishedBeyondCap()
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private async sandboxStart(
|
|
726
|
+
handle: SandboxEnvironmentIdentity,
|
|
727
|
+
request: SandboxCommandRequest,
|
|
728
|
+
): Promise<{ commandId: string }> {
|
|
729
|
+
const cwd =
|
|
730
|
+
request.cwd === undefined ? handle.workspacePath : join(handle.workspacePath, request.cwd)
|
|
731
|
+
const proc = Bun.spawn(['sh', '-c', request.command], {
|
|
732
|
+
cwd,
|
|
733
|
+
stdin: 'ignore',
|
|
734
|
+
stdout: 'pipe',
|
|
735
|
+
stderr: 'pipe',
|
|
736
|
+
env: this.sandboxEnv(),
|
|
737
|
+
})
|
|
738
|
+
const commandId = `sbcmd-${randomBytes(8).toString('hex')}`
|
|
739
|
+
this.sandboxChildren.set(commandId, { proc, stdout: '', stderr: '', exitCode: null })
|
|
740
|
+
this.evictFinishedBeyondCap()
|
|
741
|
+
this.trackChildStreams(commandId, { proc })
|
|
742
|
+
return { commandId }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async sandboxWait(
|
|
746
|
+
_handle: SandboxEnvironmentIdentity,
|
|
747
|
+
input: { commandId: string; waitSeconds: number },
|
|
748
|
+
): Promise<SandboxWaitResult> {
|
|
749
|
+
const child = this.sandboxChildren.get(input.commandId)
|
|
750
|
+
if (child === undefined) {
|
|
751
|
+
throw new SandboxOperationError(
|
|
752
|
+
'not-found',
|
|
753
|
+
`unknown sandbox command ${JSON.stringify(input.commandId)}; a restarted host loses detached-command tracking`,
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
const deadline = Date.now() + Math.min(Math.max(input.waitSeconds, 0), 300) * 1000
|
|
757
|
+
while (child.exitCode === null && Date.now() < deadline) {
|
|
758
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
759
|
+
}
|
|
760
|
+
if (child.exitCode === null)
|
|
761
|
+
return { state: 'running', stdout: child.stdout, stderr: child.stderr }
|
|
762
|
+
// Consume-on-delivery: the terminal `wait` snapshots the result and drops
|
|
763
|
+
// the entry, so a second `wait` on the same id fails typed `not-found` —
|
|
764
|
+
// indistinguishable from the restarted-host case. `running` waits never
|
|
765
|
+
// consume, so poll loops keep working unchanged.
|
|
766
|
+
const result: SandboxWaitResult = {
|
|
767
|
+
state: 'exited',
|
|
768
|
+
exitCode: child.exitCode,
|
|
769
|
+
stdout: child.stdout,
|
|
770
|
+
stderr: child.stderr,
|
|
771
|
+
}
|
|
772
|
+
this.sandboxChildren.delete(input.commandId)
|
|
773
|
+
return result
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private async sandboxRootedPath(
|
|
777
|
+
handle: SandboxEnvironmentIdentity,
|
|
778
|
+
path: string,
|
|
779
|
+
): Promise<string> {
|
|
780
|
+
if (path.includes('\\') || path.startsWith('/')) {
|
|
781
|
+
throw new SandboxOperationError(
|
|
782
|
+
'exec',
|
|
783
|
+
`sandbox path ${JSON.stringify(path)} must be relative to the checkout and use "/" separators`,
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
const rooted = resolve(handle.workspacePath, path)
|
|
787
|
+
if (
|
|
788
|
+
rooted !== handle.workspacePath &&
|
|
789
|
+
!rooted.startsWith(resolve(handle.workspacePath) + sep)
|
|
790
|
+
) {
|
|
791
|
+
throw new SandboxOperationError(
|
|
792
|
+
'exec',
|
|
793
|
+
`sandbox path ${JSON.stringify(path)} escapes the checkout`,
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
return rooted
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private async sandboxReadFile(
|
|
800
|
+
handle: SandboxEnvironmentIdentity,
|
|
801
|
+
path: string,
|
|
802
|
+
): Promise<Uint8Array> {
|
|
803
|
+
const rooted = await this.sandboxRootedPath(handle, path)
|
|
804
|
+
try {
|
|
805
|
+
return new Uint8Array(await fsReadFile(rooted))
|
|
806
|
+
} catch (error) {
|
|
807
|
+
throw new SandboxOperationError(
|
|
808
|
+
'not-found',
|
|
809
|
+
`could not read sandbox file ${JSON.stringify(path)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
810
|
+
{ cause: error },
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async sandboxWriteFile(
|
|
816
|
+
handle: SandboxEnvironmentIdentity,
|
|
817
|
+
path: string,
|
|
818
|
+
content: Uint8Array,
|
|
819
|
+
): Promise<void> {
|
|
820
|
+
const rooted = await this.sandboxRootedPath(handle, path)
|
|
821
|
+
await mkdir(resolve(rooted, '..'), { recursive: true })
|
|
822
|
+
await fsWriteFile(rooted, content)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private async sandboxRelease(input: {
|
|
826
|
+
repo: string
|
|
827
|
+
operator: string
|
|
828
|
+
environmentId: string
|
|
829
|
+
}): Promise<{ snapshots: { outcome: 'confirmed' } }> {
|
|
830
|
+
const path = resolve(input.environmentId)
|
|
831
|
+
const repo = this.repos.get(path) ?? (await this.discoverRepo(path)) ?? input.repo
|
|
832
|
+
const removed = await this.git(repo, ['worktree', 'remove', '--force', path])
|
|
833
|
+
if (
|
|
834
|
+
removed.exitCode !== 0 &&
|
|
835
|
+
!/is not a working tree|validation failed, cannot remove working tree/i.test(removed.stderr)
|
|
836
|
+
) {
|
|
837
|
+
throw new SandboxOperationError(
|
|
838
|
+
'release',
|
|
839
|
+
`could not remove operator sandbox worktree ${path}: ${removed.stderr.trim() || removed.stdout.trim() || `exit ${removed.exitCode}`}`,
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
await this.git(repo, ['worktree', 'prune'])
|
|
843
|
+
await rm(path, { recursive: true, force: true }).catch(() => {})
|
|
844
|
+
this.repos.delete(path)
|
|
845
|
+
// A local worktree has no snapshot concept; release is always complete.
|
|
846
|
+
return { snapshots: { outcome: 'confirmed' } }
|
|
847
|
+
}
|
|
848
|
+
}
|