Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versions follow [semantic versioning](https://semver.org/) (minor for new
functionality, patch for fixes and internal changes).

## [1.4.0] - 2026-08-24

### Changed

- The conversation layer moved to RubyLLM 2.0. Nothing changes in how the app
behaves — chat still streams, builds still start from the same two tools —
but self-hosters upgrading past this point should know two things. First, the
gem is pinned to a specific commit of its `main` branch rather than a
released version, because 2.0 has not shipped to RubyGems yet. Second, the
upgrade runs a **one-way** migration: it renames `models` to
`ruby_llm_models` and `tool_calls` to `ruby_llm_tool_calls` in place, moves
per-message token counts into a new `ruby_llm_usages` table, and drops the
columns it replaced. There is no `down`, and the migration runs
automatically when the container boots, so a deploy is what triggers it.
Snapshot the database first; the procedure is in
`docs/05-runbooks/04-ruby-llm-v2-rollout.md`.

## [1.3.0] - 2026-08-18

### Added
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ Hosted at **[hifumi.dev](https://hifumi.dev)** · Source: this repo.
- **Phase 3** (preview isolation via Kamal + Docker): **closed at the local-PoC level**. Button-driven start/stop, hardened Docker container (`--cap-drop=ALL`, `--read-only`, memory/CPU/pids capped, `preview-internal` network), iframe in side-by-side layout, `CleanupIdlePreviewsJob` reaps previews running >30 min, `instruction.requested` auto-stops a running preview before generation. E2E test gated by `E2E_PREVIEW=1 bin/rails test test/integration/preview_lifecycle_test.rb`.
- **Phase 4** (production deploy + multi-tenant auth): **closed**. Live at [hifumi.dev](https://hifumi.dev) on Hetzner via Kamal + kamal-proxy. Devise email/password + Sign in with GitHub (OmniAuth). Per-user OpenRouter BYOK (key encrypted at rest via Active Record `encrypts`). Production Dockerfile bundles the `claude` CLI as Roast's transport pointed at OpenRouter.
- **Post-launch review (2026-06-11)**: hardening + robustness findings recorded in `docs/04-reviews/01-post-launch-review.md`. Actioned since: the codegen agent now runs in a per-instruction isolated container (`Roast::Sandbox`); the CVE'd gems were bundle-updated (2026-06-12, CI gates on `bundler-audit`). Phase 5 remains unscoped; the review's last section lists the candidate directions discussed.
- **RubyLLM v2 upgrade (2026-08-23)**: `ruby_llm` is now a **git pin** — `crmne/ruby_llm@c45ebd78`, the unreleased 2.0 line, which still reports `VERSION 1.16.0` because the bump happens at release. Pinned rather than tracking `main` because `main` moves daily and `BUNDLE_DEPLOYMENT=1` plus `HIFUMI_AGENT_IMAGE` reusing this image make the resolved revision a reviewed decision; swap to a version constraint once 2.0 ships to RubyGems. RubyLLM now owns four tables, all via one **irreversible** migration: `ruby_llm_models` and `ruby_llm_tool_calls` are the old `models` / `tool_calls` renamed in place, while `ruby_llm_usages` (backfilled from token columns the migration then drops from `messages`) and `ruby_llm_batches` are created fresh. `Schematist::Schema` replaced `RubyLLM::Schema`, and structured output reads `.parsed` rather than `.content`. Rollout procedure: `docs/05-runbooks/04-ruby-llm-v2-rollout.md`.

Deferred observations from Phase 2 (revisit later, not blockers):
- refused-tool-call pill UX (Step 6) — the `🌀 Starting generation…` flash when the LLM ignores the state rule and Phase 5's tool guard rescues.
- refused-tool-call pill UX (Step 6) — the `🌀 Build started` pill flashing when the LLM ignores the state rule. Nothing rescues it any more: the tool-side guard was deleted in `13e9c1c`, leaving the prompt rule and the `BadRequestError` banner (see the RubyLLM idempotency bullet below).
- deferred-request handling after `✅ Generation finished.` — see `docs/09-ideas/02-deferred-request-handling.md`.
- Step 7 wall-time margin (Step 7) — real run consumed ~900s vs the spike's 496s; the integration test's `WALL_TIME_BUDGET = 900` sits right at the edge. Bump the budget or investigate W2-phase slowdown (looks heavy on the docs-update agent) before relying on this in CI.

Expand All @@ -25,7 +26,7 @@ All project documentation lives in `docs/`, grouped by topic. Folder and file nu
- **`docs/02-architecture/`** — technical canon: workflows and decisions, layer integration, tech stack, design system, and tenant isolation & preview-domain strategy (`05-tenant-isolation-and-domains.md` — why untrusted previews on a subdomain of the apex are a structural hazard, and the separate-registrable-domain antidote).
- **`docs/03-plans/`** — active implementation plans per phase (currently Phase 2 + Phase 3 analysis).
- **`docs/04-reviews/`** — point-in-time reviews of the running system. `01-post-launch-review.md` (2026-06-11) records the post-Phase-4 robustness/OSS-readiness findings; read it before planning Phase 5.
- **`docs/05-runbooks/`** — step-by-step verification procedures. `01-agent-sandbox-and-model-selection-e2e.md` verifies per-project model selection + agent-sandbox isolation, locally and on prod (`kamal app exec --reuse`); `02-preview-wildcard-tls.md` switches preview hosts from per-host on-demand Let's Encrypt to a pre-issued wildcard cert; `03-llm-model-registry.md` populates and verifies the RubyLLM model registry (`bin/verify-model-registry`) — read it before adding a model to the picker.
- **`docs/05-runbooks/`** — step-by-step verification procedures. `01-agent-sandbox-and-model-selection-e2e.md` verifies per-project model selection + agent-sandbox isolation, locally and on prod (`kamal app exec --reuse`); `02-preview-wildcard-tls.md` switches preview hosts from per-host on-demand Let's Encrypt to a pre-issued wildcard cert; `03-llm-model-registry.md` populates and verifies the RubyLLM model registry (`bin/verify-model-registry`) — read it before adding a model to the picker; `04-ruby-llm-v2-rollout.md` is the RubyLLM v2 production rollout — snapshot, deploy, verify, recover — and the migration it covers is irreversible and fires automatically on container boot, so read it before deploying that upgrade.
- **`docs/09-ideas/`** — brainstorm / idea dump (explicitly marked as non-canon).
- **`spikes/roast/`** — reference implementation of Phase 1 (proven, don't touch without reason). Future spikes: `spikes/<name>/`.

Expand Down Expand Up @@ -53,7 +54,7 @@ Additional sources from the Phase 1 spike:
- **Design system: Hifumi.** All visible chrome (colors, type, components, status tags, marketing pipeline) follows the Hifumi design system applied 2026-05-01. Tokens + component classes live in a single file: `app/assets/tailwind/application.css`. Use the tokens (`--accent`, `--paper-100`, `--ink-800`, `--hi-font-mono`, etc.) — never hardcode hex values. Status indicators are rectangular outlined boxes in mono caps with a stripe + blinking dot for live states, no emoji. Sentence case in every UI string. See `docs/02-architecture/04-design-system.md` for the full token map, component-to-view inventory, and anti-patterns.
- **Pin `.ruby-version` by writing the file** (Write tool), not via the version manager CLI (`frum local`, `rbenv local`, etc.). User wants to verify state from the file itself.
- **Roast runner**: `bin/roast-claudesubscription` is the dev default (uses Claude Code subscription — wrapper unsets `ANTHROPIC_*` ENV + pins PATH to `.ruby-version` via frum). `bin/roast-openrouter` is the per-token alternative used in production and when `FORCE_OPENROUTER=1` in dev. `bin/roast` (the bundler binstub) calls `bundle exec roast` raw, no env setup — for direct testing only. `ExecuteInstructionJob` picks `-openrouter` in production / when `FORCE_OPENROUTER=1` / whenever sandboxed, else `-claudesubscription`.
- **LLM model selection**: never hardcode a model id at a call site — `lib/llm/stages.rb` (`LLM::Stages`, note the `LLM` acronym inflection) is the registry of the six LLM stages (chat, plan_creation, plan_modification, template, code, docs), their labels, factory defaults, and the curated `AVAILABLE_MODELS` list (full OpenRouter ids only, never `sonnet`/`haiku` aliases). Profiles store per-user defaults (`default_<stage>_model`), projects snapshot their own selection (`<stage>_model`) at creation; selectors live in the build tab (`model_selections/_pane`), the new-project form, and the account integrations pane. A new stage = registry entry + migration on both tables + threading at the call site. A new **model** = confirm the OpenRouter slug supports `structured_outputs` (plan/template stages) and `tools` (chat) → populate the RubyLLM registry in **every** environment → only then add the id to `AVAILABLE_MODELS`; that order is load-bearing, because `use_new_acts_as` + `acts_as_model` make RubyLLM resolve against the `models` DB table and fall back to the gem's bundled `models.json` only when that table is *empty*, so a partially-filled table silently shadows the JSON and any model missing from it raises `ModelNotFoundError` on the four RubyLLM-backed stages (dev and prod both sat at one row until 2026-08-12). Check with `bin/verify-model-registry [candidate-id]`, fix with `Model.refresh!` plus a process restart (the registry is memoized per process) — full procedure in `docs/05-runbooks/03-llm-model-registry.md`. Selection applies on the OpenRouter path only — `roast_model_env` keeps claudesubscription runs on the operator's ENV/alias defaults, and an explicit `HIFUMI_DEV_MODEL`/`HIFUMI_DEV_DOCS_MODEL` always wins.
- **LLM model selection**: never hardcode a model id at a call site — `lib/llm/stages.rb` (`LLM::Stages`, note the `LLM` acronym inflection) is the registry of the six LLM stages (chat, plan_creation, plan_modification, template, code, docs), their labels, factory defaults, and the curated `AVAILABLE_MODELS` list (full OpenRouter ids only, never `sonnet`/`haiku` aliases). Profiles store per-user defaults (`default_<stage>_model`), projects snapshot their own selection (`<stage>_model`) at creation; selectors live in the build tab (`model_selections/_pane`), the new-project form, and the account integrations pane. A new stage = registry entry + migration on both tables + threading at the call site. A new **model** = confirm the OpenRouter slug supports `structured_outputs` (plan/template stages) and `tools` (chat) → populate the RubyLLM registry in **every** environment → only then add the id to `AVAILABLE_MODELS`; that order is load-bearing, because RubyLLM owns its registry: it resolves against the `ruby_llm_models` table (its store) and falls back to the gem's bundled `models.json` only when that table is *empty*, so an id absent from both raises `ModelNotFoundError` on the four RubyLLM-backed stages. Check with `bin/verify-model-registry [candidate-id]`, fix with `RubyLLM.models.refresh!` plus a process restart (the registry is memoized per process); the refresh only ever adds or updates rows — per-provider fetch failures are logged and skipped, never allowed to empty the store. Full procedure in `docs/05-runbooks/03-llm-model-registry.md`. Selection applies on the OpenRouter path only — `roast_model_env` keeps claudesubscription runs on the operator's ENV/alias defaults, and an explicit `HIFUMI_DEV_MODEL`/`HIFUMI_DEV_DOCS_MODEL` always wins.
- **Agent sandbox (tenant isolation)**: the codegen agent runs `claude` with `skip_permissions!` on user-controlled prompts — treat it as untrusted code execution, so in production it must NOT run in the shared generator container. `ExecuteInstructionJob#execute_revision` wraps the roast invocation via `Roast::Sandbox.wrap` (`lib/roast/sandbox.rb`, plain builder, returns the `docker run` argv) into a throwaway `--rm` container that mounts ONLY this project's workspace (no `workspace_root`, no `/var/run/docker.sock`), runs entirely as the unprivileged `generator` user (`--user`, `--cap-drop=ALL`, zero cap-adds — uniform uid avoids the capless-root/mixed-ownership deadlock of issue #24; the workspace is re-relaxed `a+rwX` before every sandboxed run), and forwards env (incl. `OPENROUTER_API_KEY` and the per-project `HIFUMI_DEV_*` model selection) **by name** so secrets never hit argv. Gated by `sandboxed?` (production OR `FORCE_AGENT_SANDBOX=1`); dev stays direct (single-tenant, Claude-subscription transport, no Docker). Image = the generator's own, from `HIFUMI_AGENT_IMAGE` (set in `deploy.yml`). Not runtime-verifiable on macOS — see the verification checklist + residuals (generator-side socket, egress, bundle vendoring) in `docs/09-ideas/05-followups.md`.
- **Preview infrastructure**: `lib/preview/preview_manager.rb` (plain Ruby, not a Roast workflow) drives Docker. `lib/preview/Dockerfile{,.base}` are owned by this repo — never read from generated apps. `lib/preview/skeleton/` is the canonical fresh-Rails-app baseline copied into every workspace; regenerate with `bin/preview-regen-skeleton` when bumping Rails. Rebuild the base image with `bin/preview-rebuild-base` after Gemfile changes. The `preview-internal` Docker network is created without `--internal` on Docker Desktop (host port mapping wouldn't work otherwise) — Phase 4 reintroduces strict egress isolation on a Linux production host. In remote mode `PreviewManager#run_container` passes `PREVIEW_HOST=<id>.preview.<domain>` to the container; the skeleton-overlay's `preview_iframe.rb` initializer appends it to `Rails.application.config.hosts` so Rails 8's dev HostAuthorization doesn't 403 the kamal-proxy request.
- **RubyLLM tools must be idempotent within a user turn**. RubyLLM's tool loop will sometimes call the same tool twice in adjacent assistant messages before either result lands, producing the order `assistant(use_X) → assistant(use_Y) → user(result_X) → user(result_Y)` — illegal for Anthropic, and the chat permanently rejects every subsequent message. Tool-side guard pattern: `SuggestPrompts#duplicate_in_turn?` returns an in-band error so the second `tool_use` still gets a `tool_result` and history stays valid. Diagnose corrupt chats with `bin/inspect-chat <project_id>` (works on prod via `kamal app exec`).
- **RubyLLM tools must be idempotent within a user turn**. RubyLLM's tool loop will sometimes call the same tool twice in adjacent assistant messages before either result lands, producing the order `assistant(use_X) → assistant(use_Y) → user(result_X) → user(result_Y)` — illegal for Anthropic, and the chat permanently rejects every subsequent message. **There is no tool-side guard any more** — the tool that carried one was deleted in `13e9c1c`. What stands today is a prompt rule (*"Call each tool AT MOST ONCE per user turn"*, `app/prompts/generator_agent/instructions.txt.erb:19`) plus the `RubyLLM::BadRequestError` banner in `ChatRespondJob::FRIENDLY_ERRORS` that turns an already-corrupt chat into *"This conversation can't be continued by the model. Please start a new project."* So a new tool needs its own in-band guard: return an error hash rather than raising, so the second `tool_use` still gets a `tool_result` and history stays valid. Diagnose corrupt chats with `bin/inspect-chat <project_id>` (works on prod via `kamal app exec`).
11 changes: 9 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,15 @@ gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"

# Conversation layer: LLM chat + tools (generator UI)
gem "ruby_llm"
# Conversation layer: LLM chat + tools (generator UI).
#
# 2.0 is unreleased — it exists only as crmne/ruby_llm@main, which still
# reports VERSION '1.16.0' because the bump happens at release. Pinned to a
# SHA rather than tracking main: main is explicitly "in development", and
# BUNDLE_DEPLOYMENT=1 plus HIFUMI_AGENT_IMAGE reusing this image mean the
# resolved revision has to be a reviewed decision. Swap to a version
# constraint once 2.0 ships to RubyGems.
gem "ruby_llm", github: "crmne/ruby_llm", ref: "c45ebd78c819b83696849a3486619d671dbafab6"

# Orchestration of generation workflows (per-revision Roast subprocess)
gem "roast-ai", "~> 1.1"
Expand Down
34 changes: 20 additions & 14 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
GIT
remote: https://github.com/crmne/ruby_llm.git
revision: c45ebd78c819b83696849a3486619d671dbafab6
ref: c45ebd78c819b83696849a3486619d671dbafab6
specs:
ruby_llm (1.16.0)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
schematist (~> 1.1)
zeitwerk (~> 2)

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -395,21 +411,11 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby_llm (1.15.0)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
rubyzip (3.3.0)
sawyer (0.9.3)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
schematist (1.1.0)
securerandom (0.4.1)
selenium-webdriver (4.44.0)
base64 (~> 0.2)
Expand Down Expand Up @@ -529,7 +535,7 @@ DEPENDENCIES
rails (~> 8.1.3)
roast-ai (~> 1.1)
rubocop-rails-omakase
ruby_llm
ruby_llm!
selenium-webdriver
solid_cable
solid_cache
Expand Down Expand Up @@ -688,10 +694,10 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
ruby_llm (1.15.0) sha256=ca207465bca1cca007010a79fce500d4012b1fbe188b025040cd37b884fb98af
ruby_llm-schema (0.3.0) sha256=a591edc5ca1b7f0304f0e2261de61ba4b3bea17be09f5cf7558153adfda3dec6
ruby_llm (1.16.0)
rubyzip (3.3.0) sha256=a372fc67892a4f8c0bc8ec906b720353d8e48807a64b2e63adf99b1e3583a034
sawyer (0.9.3) sha256=0d0f19298408047037638639fe62f4794483fb04320269169bd41af2bdcf5e41
schematist (1.1.0) sha256=905d8f0286830926e77e0d631bee325ee741c4b99640bc99735593b40ba73e7b
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.44.0) sha256=6f1df072529af369589c46f0e01132952aabb250cfd683c274d74dc1eb5d8477
snaky_hash (2.0.6) sha256=3663cae48cdef582b517025cf8a39d8789996eaf0b4ed89e2f0624836505654a
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def active_revisions_for(project)
end

def build_chat_events(project)
messages = project.chat.messages.includes(:tool_calls).to_a
messages = project.chat.messages.includes(:ruby_llm_tool_calls).to_a
status_instructions = project.instructions
.where(phase: %w[completed failed])
.to_a
Expand Down
7 changes: 5 additions & 2 deletions app/helpers/messages_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ def message_row_class(message)
end

def tool_call_pill_text(message)
call = message.tool_calls.first
# v2: message.tool_calls is a Hash keyed by provider tool-call id, whose
# values are RubyLLM::ToolCall. The persisted rows are ruby_llm_tool_calls.
calls = message.tool_calls.values
call = calls.first
case call&.name
when "create_application", "modify_application"
intent = call.arguments["intent"].to_s
intent.empty? ? "🌀 Build started" : "🌀 Build started: #{intent}"
else
"running: #{message.tool_calls.map(&:name).uniq.join(", ")}"
"running: #{calls.map(&:name).uniq.join(", ")}"
end
end

Expand Down
Loading
Loading