Class: LLMExperiment::ImageBuilder::App

Inherits:
Object
  • Object
show all
Defined in:
lib/llm_experiment/image_builder/app.rb

Overview

Builds a per-app image on top of the base image, so a trial starts instantly instead of paying for bundle install and database setup every time.

The app arrives as a git bundle containing only the experiment branches. That matters: a bundle carries committed history and nothing else, so untracked secrets, stale local bundler overrides and repository hooks on the host can never reach the image.

The image also strips the repository of anything that would let an agent read the answer instead of finding it. See FLATTENING below.

BRANCH NAMING

The source branches are not created here. The experiment's own planting script owns them, and this builder expects exactly these names:

<branch_prefix>/base       the app as it ships
<branch_prefix>/<task id>  one branch per task in experiment.yml

branch_prefix comes from the app's entry in experiment.yml. Nothing else in the name is read, and nothing else may be encoded in it: the harness this was ported from named branches exp/path-hints/bug-campfire-01-nat64-recheck and the trailing slug handed the agent the answer. A missing branch is an error, never a silently skipped task.

Images holding private code are never pushed to a registry.

Constant Summary collapse

BUILD_ROOT =
ENV.fetch("LLMX_BUILD_ROOT", "/tmp/llmx/build")

Instance Method Summary collapse

Constructor Details

#initialize(experiment:, container: Container.new, shell: Shell) ⇒ App

Returns a new instance of App.



37
38
39
40
41
# File 'lib/llm_experiment/image_builder/app.rb', line 37

def initialize(experiment:, container: Container.new, shell: Shell)
  @experiment = experiment
  @container = container
  @shell = shell
end

Instance Method Details

#branch_map(app) ⇒ Object

FLATTENING

Each experiment branch becomes one orphan commit called "Import application source", renamed to an opaque trial/<task id>.

Three leaks close here, all of which would let an agent read the answer rather than search for it:

the commit message  said "reintroduce the defect fixed in <sha>"
the commit diff     was the real fix, inverted
the branch name     carried the defect's slug, e.g. -nat64-recheck

Leaks like these do not just add noise, they bias: an agent given no path is far likelier to go digging through history than one handed the path outright, so the shortcut would help exactly the condition the experiment expects to be slowest. The trial runner closes the fourth leak by deleting every branch except the one under test, so git diff trial/base cannot reveal it either.



100
101
102
103
104
# File 'lib/llm_experiment/image_builder/app.rb', line 100

def branch_map(app)
  map = { "#{app.branch_prefix}/base" => "trial/base" }
  tasks_for(app).each { |task| map["#{app.branch_prefix}/#{task.id}"] = task.branch }
  map
end

#build(app:, no_cache: false) ⇒ Object

Raises:



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/llm_experiment/image_builder/app.rb', line 43

def build(app:, no_cache: false)
  repo = app.repo_path
  if repo.nil? || repo.empty?
    raise ConfigError,
          "LLMX_APP_#{app.key.upcase} is not set; point it at the #{app.key} checkout on this machine"
  end

  @container.ensure_disk!
  @container.ensure_builder!
  unless @container.image?(LLMExperiment.base_image)
    raise Error, "base image #{LLMExperiment.base_image} not found; run llmx build base first"
  end

  context = File.join(BUILD_ROOT, app.key)
  FileUtils.rm_rf(context)
  FileUtils.mkdir_p(context)

  raise ConfigError, "#{app.key}: no tasks in experiment.yml use this app" if tasks_for(app).empty?

  map = branch_map(app)
  require_branches!(app, repo, map)

  bundle_path = File.join(context, "app.bundle")
  @shell.log "bundling #{map.size} branch(es) from #{app.key}"
  @shell.sh("git", "-C", repo, "bundle", "create", bundle_path, *map.keys)
  # -C names the repository. `git bundle verify` resolves the bundle's
  # prerequisites against a repository, and without -C it uses the working
  # directory, which for an experiment directory is usually not one.
  @shell.sh("git", "-C", repo, "bundle", "verify", bundle_path)

  containerfile = File.join(context, "Containerfile")
  File.write(containerfile, containerfile_for(app, map))

  @shell.log "building #{app.image}"
  @container.build(tag: app.image, file: containerfile, context: context,
                   build_args: { "APP_KEY" => app.key, "RUBY_VERSION" => app.ruby },
                   no_cache: no_cache)
  @shell.log "app image ready: #{app.image} (never push: it carries the subject app's code)"
end

#containerfile_for(app, branch_map) ⇒ Object

Pure: builds the Containerfile text from the app and its branch map, so the generated recipe can be read and tested without a container or a checkout.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/llm_experiment/image_builder/app.rb', line 108

def containerfile_for(app, branch_map)
  # Cloning a bundle materialises only the checked-out branch locally; the rest
  # arrive as origin/* refs, which disappear along with the remote. Create real
  # local branches first, then drop the remote.
  localise = branch_map.keys.map { |b| "git branch -f #{b} origin/#{b} 2>/dev/null || true;" }
                       .join(" \\\n      ")

  flatten = branch_map.map do |source, target|
    "git checkout -q #{source}; git checkout -q --orphan #{target}; " \
      "#{neutralize_fragment(app)}" \
      "git add -A; git commit -q -m 'Import application source';"
  end.join(" \\\n      ")

  drop_originals = branch_map.keys.map { |b| "git branch -q -D #{b};" }.join(" ")

  containerfile = +<<~DOCKER
    FROM #{LLMExperiment.base_image}

    ARG APP_KEY
    ARG RUBY_VERSION

    ENV LLMX_APP=${APP_KEY} \\
        MISE_RUBY_VERSION=${RUBY_VERSION} \\
        RAILS_ENV=test

    USER agent
    WORKDIR /workspace

    COPY --chown=agent:agent app.bundle /home/agent/app.bundle

    RUN set -eux; \\
        git clone --branch #{app.branch_prefix}/base /home/agent/app.bundle /workspace/app; \\
        cd /workspace/app; \\
        #{localise} \\
        git remote remove origin; \\
        git config user.email dev@example.invalid; \\
        git config user.name Developer; \\
        #{flatten} \\
        #{drop_originals} \\
        git checkout -q trial/base; \\
        rm -f /home/agent/app.bundle; \\
        git reflog expire --expire=now --all; \\
        git gc --prune=now --quiet; \\
        git branch; \\
        test -z "$(git log --all --oneline --format='%s' | grep -viE '^Import application source$' || true)"

    WORKDIR /workspace/app
  DOCKER

  containerfile << postgres_block if app.database == "postgresql"
  containerfile << bundler_block(app)
  containerfile
end