feat: provide per-project dev tools through mise #269

Merged
lucas merged 1 commit from agent/issue-231-mise-dev-tools into main 2026-11-12 21:33:24 +01:00
Collaborator

Closes: #231

Implements the plan on #231: mise in the image, deck-driven provisioning, a per-workspace config in Settings, and the session context that tells the agent how tools are provided. The "detect a missing tool and open a PR" loop is deliberately out of scope, and nothing baked into the image today was removed.

What is here

Image. A separate mise build stage pinned to v2026.11.7 (MISE_VERSION + MISE_INSTALL_PATH are both honoured by the mise.run installer — verified against the script, and the pinned install verified by running it). The runtime stage copies the binary and puts /data/.local/share/mise/shims first on PATH. No MISE_*_DIR override: with HOME=/data every mise directory already lands on the volume. MISE_NOT_FOUND_AUTO_INSTALL=0 is set as an image ENV, so nothing written under /data can re-enable shim auto-install; exec_auto_install and not_found_system_fallback keep their defaults. Renovate learns the pin through a custom regex manager.

Provisioning (apps/server/src/mise/). provisionWorkspace materializes the workspace's managed config, then installs in a scratch root at <dataDir>/mise/workspaces/<id> filled from the repository's default branch (origin/HEAD, else the default branch Forgejo reports), with MISE_CEILING_PATHS set to the scratch root's parent. Never from a session cwd — mise install auto-trusts what it loads and a trigger session sits in a clone of a PR branch. Status is in-memory per workspace, concurrent calls share one run, failures are recorded and logged and never thrown at a caller. Session create/resume kicks it off without awaiting.

Per-workspace config. Migration 026, mise_config + mise_config_mode, through Workspace / WorkspaceDetails / UpdateWorkspaceRequest, the PATCH /workspaces/:id route (which re-provisions in the background) and a textarea + mode select + Save in Settings → Workspaces, with the live phase underneath. override writes mise.local.toml; replace adds a .miserc.toml narrowing override_config_filenames / override_tool_versions_filenames. Managed files carry a header, are appended once to .git/info/exclude, and a same-named file the deck did not write is never touched. Forgejo checkouts get the same config applied after clone and on restore.

Session context. The guidance block is its own system-prompt entry (not DEFAULT_PRELUDE, which a custom prelude.md would hide).

Deviation from the plan, and why

Section 4 asked to call miseSessionContext(...) inside the systemPrompt closure so the status refreshes per turn, flagged Unverified — confirm first. It does not hold: the SDK rebuilds the prompt only when its tool/model signature changes (session-tools.ts #computeAppliedToolSignature, which documents "callers must explicitly call refreshBaseSystemPrompt after side-effecting changes"), so a background install finishing would never reach a live session. The plan's own contingency is implemented instead — the static block stays in the closure, and miseStatusExtension adds the status line from before_agent_start.

One tightening in step 4 of provisioning: the on-disk fallback now runs only when no default-branch ref resolves at all. Falling back whenever the ref merely carries no mise config would read the user's working tree, which may sit on somebody else's branch.

Verification

Run against a throwaway deck DB, a real bare remote + clone, and the real pinned mise binary (not in the container; items 1, 2, 4 and 7 of the issue's list need an image build and a live trigger session and were not run):

  • A repo whose default branch declares jq = "1.7.1" provisions to phase: ok, sources: ["mise.toml"], and installs exactly 1.7.1 — while the workspace's checked-out branch declaring jq = "1.6" installs nothing. That is the pull-request-cannot-drive-an-install property, in the form that can be tested without a container.
  • Pasted config, override: mise.local.toml appears with the managed header, git status --porcelain stays empty, mise which jq in the workspace resolves to the pasted 1.7.
  • Pasted config, replace: .miserc.toml appears and mise config ls in the workspace lists only mise.local.toml — so the plan's main assumption (project .miserc.toml honours override_config_filenames) holds and the ignored_config_paths contingency is not needed.
  • A paste naming a tool that does not exist leaves phase: failed with mise's error recorded, and logs one warning.
  • Clearing the box removes both managed files and the repo's config resolves again.
  • apps/server/src/mise/materialize.test.ts covers the managed-file contract, including the refusal to clobber a user's own mise.local.toml and the once-only git exclude block.
  • bun test: 1693 pass, 0 fail. bun run typecheck and the web build are clean.
Closes: #231 Implements the plan on #231: mise in the image, deck-driven provisioning, a per-workspace config in Settings, and the session context that tells the agent how tools are provided. The "detect a missing tool and open a PR" loop is deliberately out of scope, and nothing baked into the image today was removed. ## What is here **Image.** A separate `mise` build stage pinned to `v2026.11.7` (`MISE_VERSION` + `MISE_INSTALL_PATH` are both honoured by the `mise.run` installer — verified against the script, and the pinned install verified by running it). The runtime stage copies the binary and puts `/data/.local/share/mise/shims` first on `PATH`. No `MISE_*_DIR` override: with `HOME=/data` every mise directory already lands on the volume. `MISE_NOT_FOUND_AUTO_INSTALL=0` is set as an image `ENV`, so nothing written under `/data` can re-enable shim auto-install; `exec_auto_install` and `not_found_system_fallback` keep their defaults. Renovate learns the pin through a custom regex manager. **Provisioning** (`apps/server/src/mise/`). `provisionWorkspace` materializes the workspace's managed config, then installs in a scratch root at `<dataDir>/mise/workspaces/<id>` filled from the repository's **default branch** (`origin/HEAD`, else the default branch Forgejo reports), with `MISE_CEILING_PATHS` set to the scratch root's parent. Never from a session cwd — `mise install` auto-trusts what it loads and a trigger session sits in a clone of a PR branch. Status is in-memory per workspace, concurrent calls share one run, failures are recorded and logged and never thrown at a caller. Session create/resume kicks it off without awaiting. **Per-workspace config.** Migration `026`, `mise_config` + `mise_config_mode`, through `Workspace` / `WorkspaceDetails` / `UpdateWorkspaceRequest`, the `PATCH /workspaces/:id` route (which re-provisions in the background) and a textarea + mode select + Save in Settings → Workspaces, with the live phase underneath. `override` writes `mise.local.toml`; `replace` adds a `.miserc.toml` narrowing `override_config_filenames` / `override_tool_versions_filenames`. Managed files carry a header, are appended once to `.git/info/exclude`, and a same-named file the deck did not write is never touched. Forgejo checkouts get the same config applied after clone and on restore. **Session context.** The guidance block is its own system-prompt entry (not `DEFAULT_PRELUDE`, which a custom `prelude.md` would hide). ## Deviation from the plan, and why Section 4 asked to call `miseSessionContext(...)` inside the `systemPrompt` closure so the status refreshes per turn, flagged *Unverified — confirm first*. It does not hold: the SDK rebuilds the prompt only when its tool/model signature changes (`session-tools.ts` `#computeAppliedToolSignature`, which documents "callers must explicitly call `refreshBaseSystemPrompt` after side-effecting changes"), so a background install finishing would never reach a live session. The plan's own contingency is implemented instead — the static block stays in the closure, and `miseStatusExtension` adds the status line from `before_agent_start`. One tightening in step 4 of provisioning: the on-disk fallback now runs only when *no* default-branch ref resolves at all. Falling back whenever the ref merely carries no mise config would read the user's working tree, which may sit on somebody else's branch. ## Verification Run against a throwaway deck DB, a real bare remote + clone, and the real pinned mise binary (not in the container; items 1, 2, 4 and 7 of the issue's list need an image build and a live trigger session and were not run): - A repo whose default branch declares `jq = "1.7.1"` provisions to `phase: ok`, `sources: ["mise.toml"]`, and installs exactly `1.7.1` — while the workspace's checked-out branch declaring `jq = "1.6"` installs nothing. That is the pull-request-cannot-drive-an-install property, in the form that can be tested without a container. - Pasted config, `override`: `mise.local.toml` appears with the managed header, `git status --porcelain` stays empty, `mise which jq` in the workspace resolves to the pasted `1.7`. - Pasted config, `replace`: `.miserc.toml` appears and `mise config ls` in the workspace lists **only** `mise.local.toml` — so the plan's main assumption (project `.miserc.toml` honours `override_config_filenames`) holds and the `ignored_config_paths` contingency is not needed. - A paste naming a tool that does not exist leaves `phase: failed` with mise's error recorded, and logs one warning. - Clearing the box removes both managed files and the repo's config resolves again. - `apps/server/src/mise/materialize.test.ts` covers the managed-file contract, including the refusal to clobber a user's own `mise.local.toml` and the once-only git exclude block. - `bun test`: 1693 pass, 0 fail. `bun run typecheck` and the web build are clean.
The image carries one pinned mise binary with its shim directory first on
PATH, so a tool a project declares resolves per invocation directory — the
only mechanism that works while every session shares one process
environment. HOME is the bind-mounted volume, so installs, mise's global
config and its trust decisions survive container recreation.

Opening or resuming a session in a workspace provisions that workspace's
toolchain in the background. The install runs in a scratch directory under
the data dir, filled from the repository's default branch, never from the
session's working directory: mise auto-trusts the config `mise install`
loads, and a Forgejo trigger session runs in a clone of a pull-request
branch. MISE_NOT_FOUND_AUTO_INSTALL=0 closes the same hole for shims,
while `mise exec` keeps installing on demand.

A workspace can carry its own mise config, pasted in Settings, applied
either over the repository's config or instead of it, and applied to
Forgejo checkouts of that workspace as well. The managed files carry a
header, are excluded from git, and never overwrite a file the deck did not
write.

Co-Authored-By: Claude Opus 5 via omp-deck
lucas approved these changes 2026-11-12 21:33:20 +01:00
lucas merged commit 0b940a0ac0 into main 2026-11-12 21:33:24 +01:00
lucas deleted branch agent/issue-231-mise-dev-tools 2026-11-12 21:33:25 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck!269
No description provided.