builder

package
v0.4.0-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 13, 2026 License: AGPL-3.0 Imports: 55 Imported by: 0

Documentation

Overview

Package builder implements the agent build and upgrade pipeline.

Index

Constants

View Source
const GitPollInterval = 5 * time.Minute

GitPollInterval is the cadence at which connected agents are checked for new commits on their default branch. Mirrors the OAuth refresh cadence; users behind firewalls or on providers without webhook support (Bitbucket/Gitea in v1) rely on this loop entirely.

Variables

View Source
var ErrUpgradeInProgress = errors.New("upgrade already in progress")

ErrUpgradeInProgress is returned when an upgrade is already running for the agent.

Functions

func AgentRepoPath added in v0.4.0

func AgentRepoPath(basePath, agentID string) string

Per-agent repo layout:

<basePath>/                     ← AgentReposPath
├── <agentID-1>/                ← per-agent repo working tree
│   ├── .git/
│   ├── main.go
│   ├── go.mod
│   ├── ...
├── <agentID-2>/
│   └── ...

AgentRepoPath returns the working-tree path for a single agent's repo.

func CloneAgentRepo

func CloneAgentRepo(repoPath, branch, targetDir string) error

CloneAgentRepo clones the agent's repo into targetDir on the given branch. The full source tree lives at the targetDir root (no agents/<id>/ subdir) — the per-agent repo's scaffold and codegen commits put files at the repo root.

Replaces the pre-multirepo SparseCheckout, which had to narrow a monorepo clone down to one agent's subtree.

func CommitAndPush

func CommitAndPush(workDir, message string) (string, error)

CommitAndPush commits all changes on the current branch in workDir, pushes to origin. Returns commit hash. No-op + current HEAD when the tree is clean.

func CommitScaffold

func CommitScaffold(repoPath string, data scaffold.ScaffoldData) (string, error)

CommitScaffold creates branch build/init in the agent's repo, materializes scaffold files at the repo root, and commits. Returns the commit hash. Safe to call on retry — deletes a stale branch first.

func CreateBranch

func CreateBranch(repoPath, branch string) error

CreateBranch creates a new branch from main in repoPath.

func CreateUpgradeBranch

func CreateUpgradeBranch(repoPath, runID string) error

CreateUpgradeBranch creates branch upgrade/{runID} in the agent's repo off main. Uses `-B` (create-or-reset) so retries don't fail on a leftover branch from a prior failed attempt — Fix-this-error reuses the failed run's id as the branch suffix, and successful upgrades currently never delete the branch after merging, so collisions are easy to hit. Whatever was on the old branch was from a failed run and not worth keeping.

func InitAgentRepo added in v0.4.0

func InitAgentRepo(basePath, agentID string) error

InitAgentRepo initializes a per-agent git repo at <basePath>/<agentID>/ if it doesn't already exist. Idempotent.

Sets receive.denyCurrentBranch=updateInstead so the per-upgrade clone can push back into the checked-out branch of this repo. The initial commit (with .gitignore) gives the repo a HEAD so subsequent `git checkout main` works without complaints.

func MergeBranch

func MergeBranch(repoPath, branch string) error

MergeBranch fast-forward merges branch into main, rebasing the branch onto current main first if a parallel commit advanced main while this branch's work was running. With per-agent repos each agent's history is isolated, so the rebase trivially fast-forwards in the common case; the fallback path handles the rare case of a manual commit landing on main (e.g. an operator hot-fix) in parallel with a build.

func MigrationVersionAt added in v0.4.0

func MigrationVersionAt(repoPath, commit string) (int, error)

MigrationVersionAt returns the highest goose version number among files in migrations/ at the given commit. Goose's contract is "all migrations get applied to head", so this is the version a deployed container ends up at — the answer rollback needs to give goose as its down-to target. Returns 0 when migrations/ is empty or absent.

Parses the leading NNNN_ prefix from each filename (goose's required naming convention). Non-numeric files (README, etc.) are skipped.

func RecoverAgentRepo added in v0.4.0

func RecoverAgentRepo(repoPath string) (recovered bool, err error)

RecoverAgentRepo resets an agent repo to a clean default branch before a build, undoing whatever state a previous (possibly failed) build left behind: an in-progress merge/rebase, a stale `upgrade/*` branch left checked out, and uncommitted working-tree edits (airlock's own scratch — regenerated Dockerfile, a go.mod bump that never got committed, etc.).

Without this, housekeeping (which runs before codegen's own checkout-main) operates on whatever branch was left checked out and commits there, so its go.mod bump never lands on main and the codegen clone takes main's stale committed go.mod.

`git checkout -f main` switches to main AND discards working-tree changes in one shot. The failed `upgrade/*` branches are NOT deleted — they're kept for manual recovery; we just stop being checked out on one. The working-tree edits we discard are never user work: user/codegen changes reach the repo as commits (the external remote → PullAgentRepo → main, or codegen's push-back), never as uncommitted edits in this tree.

No-op on a fresh path with no .git/ (initial build hasn't run `git init` yet). Returns true iff anything actually needed cleaning up, so the caller can log it — silent recovery hides the underlying crash/abandon pattern.

func RemoveAgentRepo added in v0.4.0

func RemoveAgentRepo(basePath, agentID string) error

RemoveAgentRepo deletes the per-agent repo directory entirely. Idempotent — missing dir is not an error.

func ResetHard added in v0.4.0

func ResetHard(repoPath, commit string) error

ResetHard moves the current branch HEAD to commit and forces the working tree to match. Caller is responsible for saving any forward commits with SaveRef first — they'll be unreachable from any branch after this returns (recoverable via reflog for the default 90-day window). Checks out main first so reset hits the right ref.

func SaveRef added in v0.4.0

func SaveRef(repoPath, refName, commit string) error

SaveRef creates a branch ref pointing at the given commit. Fails loud if the ref already exists — callers pick unique names (typically pre-rollback/{timestamp}) so a collision implies a bug, not a retry-safe noop.

func UpgradeBranchName added in v0.4.0

func UpgradeBranchName(runID string) string

UpgradeBranchName returns the branch name an upgrade run lives on inside the agent's repo. Exposed so callers can hand the same name to CloneAgentRepo and MergeBranch.

Types

type AutoFixContext added in v0.4.0

type AutoFixContext struct {
	ErrorMessage string
	PanicTrace   string
	InputPayload string
	Actions      string
	Messages     string
	Logs         string // captured log lines from the failed run
}

AutoFixContext is the run-failure trail that gets written into DIAGNOSTICS.md before Sol sees the workspace, so the builder agent can diagnose the failure. Mirrors the auto-fix subset of UpgradeInput.

type BuildInput

type BuildInput struct {
	AgentID         string
	Name            string
	Slug            string
	UserID          string
	BuildProviderID pgtype.UUID // providers row FK; pairs with BuildModel
	BuildModel      string      // bare model name; "" + invalid FK ⇄ inherit system default
	Instructions    string      // optional: when non-empty, run Sol code generation after scaffold

	// Optional external-git connection. When GitRemoteURL is non-empty,
	// the agent is connected to the remote during Build and the first
	// push happens via Execute's post-merge push (Phase C2). The API
	// handler enforces that GitCredentialID belongs to UserID before
	// calling Build.
	GitRemoteURL     string
	GitCredentialID  pgtype.UUID
	GitDefaultBranch string // defaults to "main" when empty
}

BuildInput describes what to build.

type BuildKind added in v0.4.0

type BuildKind string

BuildKind discriminates the three flows that share the Execute pipeline.

const (
	BuildKindBuild    BuildKind = "build"
	BuildKindUpgrade  BuildKind = "upgrade"
	BuildKindRollback BuildKind = "rollback"
)

type BuildPlan added in v0.4.0

type BuildPlan struct {
	Agent dbq.Agent
	Kind  BuildKind

	StartCommit    string
	PreserveBranch string

	Instruction string

	RollbackTargetID pgtype.UUID

	Scaffold *ScaffoldInputs

	RunID          string
	ConversationID string
	Reason         string

	Diagnostics *AutoFixContext
}

BuildPlan is the single input to Execute. Each external entry point (Build, RunUpgrade, Rollback) constructs one of these and hands it off; Execute is the only function that contains the full pipeline.

Fields are interpreted as follows:

  • Agent / Kind / RunID / Reason / ConversationID: always required.
  • Scaffold: required iff Kind=build (the repo doesn't exist yet).
  • StartCommit + PreserveBranch: rollback only — Phase B saves current HEAD under PreserveBranch then resets main to StartCommit.
  • Instruction: empty = no Sol invocation (bare rebuild / pure rollback); non-empty = Sol runs with this as the user-turn prompt and the string is also persisted on agent_builds.instructions.
  • RollbackTargetID: rollback only — points the new agent_builds row at the build we're rolling back to (UI uses it for the label).
  • Diagnostics: upgrade auto_fix only — writes DIAGNOSTICS.md into the codegen workspace before Sol sees it.

type BuildService

type BuildService struct {
	// contains filtered or unexported fields
}

BuildService orchestrates the agent build and upgrade pipeline.

func New

func New(cfg *config.Config, database *db.DB, containers container.ContainerManager, encryptor secrets.Store, logger *zap.Logger) *BuildService

New creates a BuildService. Panics if any dependency is nil.

func (*BuildService) AcquireUpgradeLock

func (b *BuildService) AcquireUpgradeLock(ctx context.Context, agentID string) error

Upgrade runs the upgrade pipeline for an existing agent. This is synchronous — caller should run in a goroutine if needed. AcquireUpgradeLock atomically checks that no upgrade is running for the agent and sets upgrade_status to "building". Returns ErrUpgradeInProgress if an upgrade is already active. Call RunUpgrade after a successful lock.

func (*BuildService) AgentRepoPath added in v0.4.0

func (b *BuildService) AgentRepoPath(agentID string) string

AgentRepoPath returns the on-disk path for a single agent's repo.

func (*BuildService) Build

func (b *BuildService) Build(_ context.Context, input BuildInput) error

Build runs the initial-build pipeline: scaffold → Sol codegen (if instructions present) → docker build → start container. Thin wrapper over Execute that handles the build-specific outer lifecycle: load or create the agent row, flip agents.status to building, route failures into agents.status=failed. Synchronous; caller runs in a goroutine.

func (*BuildService) CancelBuild

func (b *BuildService) CancelBuild(agentID string) bool

CancelBuild cancels a running build/upgrade for the given agent. Returns true if a build was running and cancelled. Does not block on teardown — use CancelBuildAndWait when the caller needs the toolserver and DB writes to settle before proceeding.

func (*BuildService) CancelBuildAndWait added in v0.2.8

func (b *BuildService) CancelBuildAndWait(agentID string, timeout time.Duration) bool

CancelBuildAndWait cancels a running build/upgrade and blocks until the goroutine has run its deferred cleanup (toolserver SIGKILL, DB status write) or until timeout elapses. Returns true if a build was running. Used by Delete to avoid racing the workspace rm and agent-row delete against the upgrade's in-flight writes.

func (*BuildService) Execute added in v0.4.0

func (b *BuildService) Execute(ctx context.Context, plan BuildPlan) (string, error)

Execute is the single pipeline shared by Build, RunUpgrade, and Rollback. Each entry point constructs a BuildPlan and hands it off here; the differences between flows collapse into conditional phases keyed off plan.Kind / plan.Instruction / plan.StartCommit.

Phases:

A.  Lock + per-flow setup (build creates the agent record + schema;
    upgrade/rollback acquire the upgrade lock and load the agent).
A4. Insert the agent_builds row so the "started" event carries its id.
B.  Reposition the repo if StartCommit is set (rollback only): save
    current HEAD as PreserveBranch, then git reset --hard.
C.  Codegen if Instruction is non-empty: run Sol on a working
    branch, commit, merge back.
D.  Build the docker image at current HEAD.
E.  Validate migrations on a schema clone — up→down→up for
    build/upgrade; the rollback-specific down-to dry run is wired in
    by Phase 4 of the plan and lives in a sibling helper.
F.  Swap the agent container (stop old if present, start new).
G/H. Update agents.{source_ref,image_ref,status} and complete the
    agent_builds row.

Returns the agent-builder's exit-tool message (when Sol ran) so the caller can plumb it into the originating conversation. Empty string is fine when no Sol ran.

func (*BuildService) PullAgentRepo added in v0.4.0

func (b *BuildService) PullAgentRepo(ctx context.Context, agent dbq.Agent) (string, error)

PullAgentRepo fetches the configured remote branch and fast-forwards the local main to it. Used by the webhook receiver: when a user push is announced, airlock pulls before enqueueing a rebuild so Execute sees the new HEAD. Errors if the local working tree has uncommitted changes (shouldn't happen in steady state).

func (*BuildService) RebuildAllOnSDKChange added in v0.4.0

func (b *BuildService) RebuildAllOnSDKChange(ctx context.Context)

RebuildAllOnSDKChange checks whether the airlock-bundled agentsdk version differs from the last-seen value persisted in system_settings, and if so kicks off a bounded-concurrency rebuild of every active/stopped agent. Designed to run once at airlock startup, after migrations have applied — the comparison itself is cheap, the rebuild only happens on actual drift.

On success (image rebuilt, container restarted, migrations validated), the agent's existing status is preserved. On failure, the agent is transitioned to status=stopped with the build error captured in error_message — the operator decides whether to roll back, run an upgrade-with-description (Sol bridges the SDK gap), or investigate.

last_seen_sdk_version is updated only AFTER every agent has been processed (regardless of individual successes) so a crash mid-batch re-triggers the same rebuild next boot. This is safe: a successful rebuild is idempotent (same source_ref → same image hash → image build cache hit → near-instant), and unchanged agents short-circuit in Execute's image-build phase.

func (*BuildService) RecoverStuckOperations

func (b *BuildService) RecoverStuckOperations(ctx context.Context) error

RecoverStuckOperations resets any builds or upgrades left in progress after an unclean shutdown. Should be called on startup.

func (*BuildService) ReposPath added in v0.4.0

func (b *BuildService) ReposPath() string

ReposPath returns the base directory holding per-agent git repos. Each agent's source lives at <ReposPath>/<agentID>/.

func (*BuildService) Rollback added in v0.4.0

func (b *BuildService) Rollback(_ context.Context, in RollbackInput)

Rollback reverses an agent to a previous build's source_ref. Wraps Execute the same way RunUpgrade does — the rollback-specific work (loading the target build, deciding whether an SDK gap needs Sol, recording the pre-rollback branch name) all happens here before the plan is handed to Execute; Execute itself doesn't know about rollback semantics beyond Phase B (reposition repo) and the rollback_target_id on the new agent_builds row.

Synchronous; caller runs in a goroutine. Caller is responsible for AcquireUpgradeLock before calling — same gate as RunUpgrade so concurrent upgrades and rollbacks can't race.

func (*BuildService) RunGitPoll added in v0.4.0

func (b *BuildService) RunGitPoll(ctx context.Context)

RunGitPoll starts the periodic ls-remote loop. Blocks until ctx is cancelled. Runs an immediate poll on startup so connected agents whose remotes advanced while airlock was down are caught without waiting for the first tick.

func (*BuildService) RunUpgrade

func (b *BuildService) RunUpgrade(_ context.Context, input UpgradeInput)

RunUpgrade executes the upgrade pipeline for an agent whose upgrade_status was set to "building" via AcquireUpgradeLock. Thin wrapper over Execute that handles the upgrade-specific outer lifecycle: route Execute's outcome into agents.upgrade_status and post the single conversation message describing the result.

func (*BuildService) SetEventPublisher

func (b *BuildService) SetEventPublisher(ep EventPublisher)

SetEventPublisher sets the event publisher for build/upgrade lifecycle events.

func (*BuildService) SetUpgradeNotifier

func (b *BuildService) SetUpgradeNotifier(n PostUpgradeNotifier)

SetUpgradeNotifier sets the notifier called after an upgrade initiated from an agent's web/bridge/A2A conversation finishes.

func (*BuildService) SetUpgradeSystemNotifier added in v0.4.0

func (b *BuildService) SetUpgradeSystemNotifier(n PostUpgradeSystemNotifier)

SetUpgradeSystemNotifier sets the notifier called after an upgrade initiated from a system-agent conversation finishes. Mirrors SetUpgradeNotifier; the builder routes by UpgradeInput's SystemConversationID vs ConversationID (mutually exclusive — see notifyUpgradeOutcome).

func (*BuildService) WarmBuildCache

func (b *BuildService) WarmBuildCache(ctx context.Context)

WarmBuildCache pre-downloads Go module dependencies so that the first real agent build hits a warm cache. Materializes a real scaffold and runs a full docker build (same Dockerfile.tmpl + go.mod.tmpl as real builds), then removes the throwaway image. The cache mounts persist.

func (*BuildService) WarmRuntimeCaches

func (b *BuildService) WarmRuntimeCaches(ctx context.Context)

WarmRuntimeCaches seeds the named volumes that the build-prompt loop's direct `go mod tidy` / `go build` invocations consume — distinct from the BuildKit cache mount that WarmBuildCache populates. The agent-builder container at runtime sets GOMODCACHE=/tmp/go-mod and GOCACHE=/tmp/go-cache (see container/docker.go) backed by the airlock-go-mod-cache and airlock-go-build-cache Docker named volumes. Without this seed, the first build-prompt iteration pays full download cost for ~25 modules in agentsdk+sol's transitive dep tree. The volumes persist across airlock restarts.

type BuilderPromptData

type BuilderPromptData struct {
	HasWebSearch bool
}

BuilderPromptData holds template data for the builder system prompt.

type EventPublisher

type EventPublisher interface {
	PublishBuildEvent(ctx context.Context, agentID, buildID uuid.UUID, status, errMsg string)
	PublishBuildLogLine(ctx context.Context, agentID, buildID uuid.UUID, seq int64, stream, line string)
}

EventPublisher publishes build/upgrade lifecycle events. Implemented by realtime.PubSub via an adapter.

type HousekeepingResult added in v0.4.0

type HousekeepingResult struct {
	DockerfileChanged bool
	AgentsMDChanged   bool
	GitignoreChanged  bool
	GoModChanged      bool
}

HousekeepingResult reports what runHousekeeping changed in the agent repo so the caller can decide whether to make a chore commit.

func (HousekeepingResult) Changed added in v0.4.0

func (r HousekeepingResult) Changed() bool

Changed returns true if any airlock-managed file was rewritten.

type LibsPaths

type LibsPaths struct {
	// Owned is the directory containing agentsdk/goai/sol. Either the
	// operator's AGENT_LIBS_PATH (live dev source) or the extracted cache.
	Owned string
	// Ext is the directory containing goose/templ. Always the extracted
	// cache (devs don't edit these).
	Ext string
}

LibsPaths is what callers need to wire up the per-agent docker build's two BuildKit contexts (libs-owned + libs-ext) and the toolserver's bind-mount overlay (Owned).

func EnsureLibs

func EnsureLibs(ctx context.Context, image, explicitPath, cacheDir string, logger *zap.Logger) (LibsPaths, error)

EnsureLibs guarantees that:

  • The agent-builder image's full /libs/* set is materialized on the host under <cacheDir>/<image-id>/, cached by image ID so a tag bump triggers re-extraction.
  • If explicitPath (AGENT_LIBS_PATH) is set, the owned subdirs all exist there. Owned then points at explicitPath; Ext at the cache.
  • If explicitPath is empty (prod), both Owned and Ext point at the cache.

type PostUpgradeNotifier

type PostUpgradeNotifier interface {
	NotifyUpgradeComplete(ctx context.Context, agentID uuid.UUID, conversationID, status, message string) error
}

PostUpgradeNotifier is called after an upgrade finishes (success, failure, or refusal) to post a single message into the originating agent conversation.

status is "success", "error", or "refused". message is the human-readable text to surface — typically sourced from the agent-builder's exit tool on success, the underlying failure reason on error, or the out-of-scope explanation on refused. The notifier posts exactly ONE message and does not trigger a follow-up LLM turn — the agent's own exit-tool summary already describes the outcome, so re-prompting just produces redundant text.

type PostUpgradeSystemNotifier added in v0.4.0

type PostUpgradeSystemNotifier interface {
	NotifyUpgradeComplete(ctx context.Context, agentID, conversationID uuid.UUID, status, message string) error
}

PostUpgradeSystemNotifier is the parallel sink for upgrades initiated from the in-airlock system agent (not from an agent's own conversation). Same status/message contract as PostUpgradeNotifier; the target is the system_conversations.id of the conversation that triggered the upgrade. Exactly one of {ConversationID, SystemConversationID} is set on any given UpgradeInput; the builder picks the notifier accordingly.

type PushConflictError added in v0.4.0

type PushConflictError struct {
	PreservedBranch string
	RemoteBranch    string
}

PushConflictError signals that the codegen commit conflicted with a concurrent user push on the same branch. The conflicting codegen work is preserved on PreservedBranch (e.g. "airlock/upgrade/{runID}") pushed to the remote (and also kept as a local ref). The local main is reset to the remote tip so subsequent builds start clean.

func (*PushConflictError) Error added in v0.4.0

func (e *PushConflictError) Error() string

type RefusedError added in v0.4.0

type RefusedError struct {
	Message string
}

RefusedError signals that the agent-builder declined a request as outside agentsdk's scope (exit status "refused") rather than failing while attempting in-scope work. The pipeline maps it onto a non-alarming "declined" outcome — agent_builds.status="refused", the agent left untouched — instead of a build failure.

func (*RefusedError) Error added in v0.4.0

func (e *RefusedError) Error() string

type RollbackInput added in v0.4.0

type RollbackInput struct {
	AgentID              string
	BuildID              string
	ConversationID       string
	SystemConversationID string
}

RollbackInput describes a rollback request: the agent whose state we want to move backwards, and the agent_builds row that defines the target (its source_ref becomes main; its image_ref is rebuilt).

ConversationID and SystemConversationID are mutually exclusive: a rollback triggered from a web/bridge/A2A agent conversation sets the former; one triggered from a system-agent conversation sets the latter. The post-build outcome routes to whichever was set — see BuildService.notifyUpgradeOutcome.

type ScaffoldInputs added in v0.4.0

type ScaffoldInputs struct {
	Name            string
	Slug            string
	BuildProviderID pgtype.UUID
	BuildModel      string
}

ScaffoldInputs carries the per-build creation parameters that only the initial-build flow uses. Lives on BuildPlan as an optional pointer so Kind=upgrade / Kind=rollback don't have to pass nil fields, and so Execute's "is this a fresh build?" check is the presence of the pointer instead of a magic string compare.

type UpgradeInput

type UpgradeInput struct {
	AgentID              string
	RunID                string // the run that triggered the upgrade
	Reason               string // "llm_request", "auto_fix", "manual"
	Description          string // what to change
	ConversationID       string // conversation that triggered the upgrade (for post-upgrade reply)
	SystemConversationID string // system-agent conversation that triggered the upgrade (mutually exclusive with ConversationID)
	ErrorMessage         string // from failed run (auto_fix)
	PanicTrace           string // from failed run (auto_fix)
	InputPayload         string // JSON of failed run input (auto_fix)
	Actions              string // JSON of recorded actions before failure (auto_fix)
	Messages             string // conversation messages from the failed run
	Logs                 string // captured log lines from the failed run (auto_fix)
}

UpgradeInput describes an upgrade request.

ConversationID and SystemConversationID are mutually exclusive: an upgrade triggered from a web/bridge/A2A agent conversation sets the former; one triggered from a system-agent conversation sets the latter. The post-build outcome is routed to whichever was set — see BuildService.notifyUpgradeOutcome.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL