Compare commits

...

4 Commits

Author SHA1 Message Date
Developer fc52353b2e fix: break config profile refresh import cycle 2026-07-21 15:46:35 +00:00
Developer a63a983116 feat: merge live Git config mount refresh 2026-07-21 15:37:34 +00:00
Developer 886c863260 feat: refresh shared Git config mounts live
- Use profile-scoped canonical Git clone sources with locked refreshes
- Mount shared Git configuration read-only and isolate profile content
- Add API and desktop/mobile actions for live Git mount refresh

Quality gates: frontend build and backend py_compile passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
2026-07-21 14:37:42 +00:00
Developer 3c25fffd49 feat: merge live config profile refresh 2026-07-21 11:36:56 +00:00
11 changed files with 257 additions and 81 deletions
@@ -1,6 +1,7 @@
"""Config profile API endpoints.""" """Config profile API endpoints."""
import logging import logging
import os
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -174,6 +175,47 @@ async def update_config_profile(
return response return response
@router.post("/{profile_id}/refresh-git-mounts")
async def refresh_profile_git_mounts(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Refresh canonical Git mount sources used by running profile instances."""
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Import lazily: instance_service imports tool schemas that transitively
# load API routers, so importing it during router initialization cycles.
from src.services.tool.instance_service import resolve_git_mounts
outcomes = await _running_profile_outcomes(session, profile.id)
for outcome in outcomes:
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
if (
instance is None
or not instance.compose_path
or instance.selected_config_profile_id is None
):
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
await resolve_git_mounts(
session,
resolved,
os.path.dirname(instance.compose_path),
)
outcome["status"] = "refreshed"
outcome["reason"] = "Canonical Git mount source refreshed in place"
return {"refresh_outcomes": outcomes}
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile( async def delete_config_profile(
profile_id: str, profile_id: str,
+77 -53
View File
@@ -2,7 +2,9 @@
import asyncio import asyncio
import contextlib import contextlib
import fcntl
import glob as glob_module import glob as glob_module
import hashlib
import logging import logging
import os import os
import re import re
@@ -158,57 +160,23 @@ def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict], profile_mounts: list[dict],
git_mount_volumes: list[dict], git_mount_volumes: list[dict],
) -> list[dict]: ) -> list[dict]:
"""Merge profile file mounts into overlapping git-mount sources. """Leave profile and Git sources isolated.
When a config profile mounts static files to the same directory as a Git mount sources are shared, read-only canonical checkouts. Profile
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile content must never be copied into one because doing so dirties the
would mask the cloned repository. Instead, copy the profile files into checkout and leaks one profile's content to every instance using it.
the git-mount source directory so the container sees both sets of files
through a single bind mount.
Profile mounts whose target is a child of a git-mount target are copied
into the corresponding subdirectory. Mounts that do not overlap are
returned unchanged.
""" """
remaining: list[dict] = [] for profile_mount in profile_mounts:
for pvol in profile_mounts: profile_target = profile_mount.get("target", "")
p_source = pvol.get("source", "") for git_mount in git_mount_volumes:
p_target = pvol.get("target", "") if _relative_under(git_mount.get("target", ""), profile_target) is not None:
if not p_source or not os.path.exists(p_source): logger.warning(
remaining.append(pvol) "Config profile mount %s overlaps Git mount %s; keeping sources isolated",
continue profile_target,
git_mount.get("target"),
merged = False
for gvol in git_mount_volumes:
g_source = gvol.get("source", "")
g_target = gvol.get("target", "")
if not g_source or not os.path.isdir(g_source):
continue
rel = _relative_under(g_target, p_target)
if rel is None:
continue
dst = os.path.join(g_source, rel) if rel else g_source
if os.path.isdir(p_source):
shutil.copytree(p_source, dst, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(p_source, dst)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
) )
merged = True
break break
return profile_mounts
if not merged:
remaining.append(pvol)
return remaining
async def resolve_git_mounts( async def resolve_git_mounts(
@@ -227,12 +195,18 @@ async def resolve_git_mounts(
if not resolved.git_mounts: if not resolved.git_mounts:
return [] return []
# Git mount sources are profile-scoped, not instance-scoped, so compatible
# instances bind the same canonical checkout.
clone_parent = os.path.join(
os.path.dirname(instance_dir or ""), "config-profiles", str(resolved.profile_id)
)
# Process all git mounts concurrently # Process all git mounts concurrently
tasks = [] tasks = []
for git_mount in resolved.git_mounts: for git_mount in resolved.git_mounts:
tasks.append( tasks.append(
resolve_single_git_mount( resolve_single_git_mount(
session, git_mount, instance_dir, working_directory, home_dir session, git_mount, clone_parent, working_directory, home_dir
) )
) )
@@ -265,6 +239,37 @@ def normalize_git_mount(entry: dict) -> dict:
return entry return entry
@contextlib.contextmanager
def _git_mount_lock(clone_parent: str, remote_url: str, branch: str | None):
"""Serialize clone and refresh operations for one canonical Git source."""
lock_dir = os.path.join(clone_parent, "git-mounts")
identity = f"{remote_url}:{branch or 'default'}"
lock_path = os.path.join(
lock_dir, f".{hashlib.sha256(identity.encode()).hexdigest()}.lock"
)
try:
os.makedirs(lock_dir, exist_ok=True)
with open(lock_path, "a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except OSError as exc:
raise RuntimeError(f"Cannot lock Git mount source: {lock_path}") from exc
def clone_git_repo_locked(
remote_url: str,
branch: str | None,
clone_parent: str,
project_name: str | None = None,
) -> str:
"""Clone or refresh a canonical source while holding its process lock."""
with _git_mount_lock(clone_parent, remote_url, branch):
return clone_git_repo(remote_url, branch, clone_parent, project_name)
def clone_git_repo( def clone_git_repo(
remote_url: str, remote_url: str,
branch: str | None, branch: str | None,
@@ -425,7 +430,7 @@ def resolve_git_mount_mappings(
async def resolve_single_git_mount( async def resolve_single_git_mount(
session: AsyncSession, session: AsyncSession,
git_mount: dict, git_mount: dict,
instance_dir: str | None = None, clone_parent: str | None = None,
working_directory: str | None = None, working_directory: str | None = None,
home_dir: str = "/root", home_dir: str = "/root",
) -> list[dict]: ) -> list[dict]:
@@ -447,15 +452,15 @@ async def resolve_single_git_mount(
logger.warning("Invalid git mount skipped: no mappings") logger.warning("Invalid git mount skipped: no mappings")
return [] return []
if not instance_dir: if not clone_parent:
logger.warning("Git mount skipped: no instance_dir provided for cloning") logger.warning("Git mount skipped: no canonical profile directory provided")
return [] return []
# Clone or pull the repository. Git mounts are auxiliary, so they keep # Clone or pull the repository. Git mounts are auxiliary, so they keep
# using the repository URL basename rather than the project name. # using the repository URL basename rather than the project name.
try: try:
repo_path = await asyncio.to_thread( repo_path = await asyncio.to_thread(
clone_git_repo, remote_url, branch, instance_dir clone_git_repo_locked, remote_url, branch, clone_parent
) )
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -468,7 +473,12 @@ async def resolve_single_git_mount(
return [] return []
# Resolve all mappings from the cloned repo # Resolve all mappings from the cloned repo
return resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir) volumes = resolve_git_mount_mappings(
repo_path, mappings, working_directory, home_dir
)
for volume in volumes:
volume["readonly"] = True
return volumes
def checkout_branch(repo_path: str, branch: str) -> bool: def checkout_branch(repo_path: str, branch: str) -> bool:
@@ -1481,7 +1491,15 @@ async def start_tool_instance(
if ssh_keys_to_mount: if ssh_keys_to_mount:
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh") ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
try:
os.makedirs(ssh_dir, exist_ok=True) os.makedirs(ssh_dir, exist_ok=True)
except OSError as exc:
logger.error(
"Failed to create SSH mount directory for instance %s: %s",
instance.id,
exc,
)
ssh_keys_to_mount = []
key_filenames = [] key_filenames = []
for ssh_key in ssh_keys_to_mount: for ssh_key in ssh_keys_to_mount:
@@ -2194,7 +2212,13 @@ async def delete_tool_instance(
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
import shutil import shutil
try:
shutil.rmtree(instance_dir) shutil.rmtree(instance_dir)
except OSError as exc:
logger.error(
"Failed to remove instance directory %s: %s", instance_dir, exc
)
raise RuntimeError("Failed to remove instance files") from exc
await publish_lifecycle_event( await publish_lifecycle_event(
event_bus=_event_bus, event_bus=_event_bus,
+18 -23
View File
@@ -80,12 +80,7 @@ class TestCloneGitRepo:
branch = None branch = None
clone_parent = str(tmp_path) clone_parent = str(tmp_path)
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12] url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
repo_path = ( repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
tmp_path
/ "git-mounts"
/ f"dotfiles-{url_hash}"
/ "repo-clone"
)
(repo_path / ".git").mkdir(parents=True) (repo_path / ".git").mkdir(parents=True)
clone = MagicMock(side_effect=AssertionError("existing clone must be reused")) clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
@@ -99,7 +94,9 @@ class TestCloneGitRepo:
clone.assert_not_called() clone.assert_not_called()
pull.assert_called_once_with(str(repo_path), remote_url) pull.assert_called_once_with(str(repo_path), remote_url)
def test_replaces_incomplete_clone_before_retry(self, monkeypatch, tmp_path) -> None: def test_replaces_incomplete_clone_before_retry(
self, monkeypatch, tmp_path
) -> None:
from src.services.tool import instance_service from src.services.tool import instance_service
remote_url = "https://gitlab.com/example/dotfiles" remote_url = "https://gitlab.com/example/dotfiles"
@@ -128,10 +125,10 @@ class TestCloneGitRepo:
class TestStackProfileMountsWithGitMounts: class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts.""" """Tests for _stack_profile_mounts_with_git_mounts."""
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None: def test_exact_overlap_keeps_profile_files_out_of_git_source(
"""When a profile mount targets the same directory as a git mount, self, tmp_path
the profile files should be copied into the git-mount source so the ) -> None:
container sees both sets of files through one bind mount.""" """Overlaps must not dirty the shared Git checkout."""
git_source = tmp_path / "git" / "repo-clone" git_source = tmp_path / "git" / "repo-clone"
git_source.mkdir(parents=True) git_source.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git") (git_source / "existing.txt").write_text("from git")
@@ -156,13 +153,12 @@ class TestStackProfileMountsWithGitMounts:
profile_mounts, git_mount_volumes profile_mounts, git_mount_volumes
) )
assert result == [] assert result == profile_mounts
assert (git_source / "existing.txt").read_text() == "from git" assert (git_source / "existing.txt").read_text() == "from git"
assert (git_source / "settings.json").read_text() == "{}" assert not (git_source / "settings.json").exists()
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None: def test_descendant_overlap_keeps_sources_isolated(self, tmp_path) -> None:
"""Profile mounts targeting a child directory are copied into the """A child profile mount must not mutate the shared Git checkout."""
corresponding subdirectory of the git-mount source."""
git_source = tmp_path / "git" git_source = tmp_path / "git"
git_source.mkdir() git_source.mkdir()
(git_source / "README").write_text("repo") (git_source / "README").write_text("repo")
@@ -186,8 +182,8 @@ class TestStackProfileMountsWithGitMounts:
profile_mounts, git_mount_volumes profile_mounts, git_mount_volumes
) )
assert result == [] assert result == profile_mounts
assert (git_source / "agent" / "settings.json").read_text() == "x" assert not (git_source / "agent").exists()
assert (git_source / "README").read_text() == "repo" assert (git_source / "README").read_text() == "repo"
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None: def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
@@ -247,9 +243,8 @@ class TestStackProfileMountsWithGitMounts:
assert result == profile_mounts assert result == profile_mounts
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None: def test_profile_source_file_does_not_mutate_git_source(self, tmp_path) -> None:
"""A profile mount that supplies a single file is copied into the """A profile file must not be copied into a shared Git checkout."""
git-mount source directory."""
git_source = tmp_path / "git" git_source = tmp_path / "git"
git_source.mkdir() git_source.mkdir()
@@ -271,8 +266,8 @@ class TestStackProfileMountsWithGitMounts:
profile_mounts, git_mount_volumes profile_mounts, git_mount_volumes
) )
assert result == [] assert result == profile_mounts
assert (git_source / "settings.json").read_text() == "{}" assert not (git_source / "settings.json").exists()
@pytest.mark.unit @pytest.mark.unit
+10 -1
View File
@@ -2,7 +2,7 @@ import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome { export interface ConfigProfileRefreshOutcome {
instance_id: string; instance_id: string;
status: "compatible" | "restart_required" | "incompatible_permissions"; status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
reason?: string; reason?: string;
} }
@@ -145,6 +145,15 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`); await apiClient.delete(`/config-profiles/${id}`);
}; };
export const refreshConfigProfileGitMounts = async (
id: string,
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
const response = await apiClient.post<{
refresh_outcomes: ConfigProfileRefreshOutcome[];
}>(`/config-profiles/${id}/refresh-git-mounts`);
return response.data;
};
export const updateProfileIncludes = async ( export const updateProfileIncludes = async (
id: string, id: string,
data: UpdateIncludesRequest, data: UpdateIncludesRequest,
@@ -23,6 +23,7 @@ interface Props {
onSubmit: (e?: React.FormEvent) => void; onSubmit: (e?: React.FormEvent) => void;
onReset: () => void; onReset: () => void;
onPreview: () => void; onPreview: () => void;
onRefreshGitMounts: () => void;
onAddInclude: (id: string) => void; onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void; onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void; onDragStart: (e: React.DragEvent, index: number) => void;
@@ -63,6 +64,7 @@ export const ConfigProfileEditorPanel = ({
onSubmit, onSubmit,
onReset, onReset,
onPreview, onPreview,
onRefreshGitMounts,
onAddInclude, onAddInclude,
onRemoveInclude, onRemoveInclude,
onDragStart, onDragStart,
@@ -114,6 +116,9 @@ export const ConfigProfileEditorPanel = ({
</div> </div>
{!isCreating && selectedProfile && ( {!isCreating && selectedProfile && (
<div className="row row-sm"> <div className="row row-sm">
<button className="btn btn-secondary" onClick={onRefreshGitMounts}>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</button>
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}> <button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? ( {previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</> <><Icon name="loading" size="sm" /> Previewing...</>
@@ -64,6 +64,7 @@ interface Props {
) => void; ) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void; onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void; onPreview: (id: string) => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void; onClosePreview: () => void;
} }
@@ -104,6 +105,7 @@ export const ConfigProfilesMobileView = ({
onUpdateMountFile, onUpdateMountFile,
onRemoveMountFile, onRemoveMountFile,
onPreview, onPreview,
onRefreshGitMounts,
onClosePreview, onClosePreview,
}: Props) => { }: Props) => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -363,6 +365,13 @@ export const ConfigProfilesMobileView = ({
onDelete={handleDeleteClick} onDelete={handleDeleteClick}
> >
<div className="mobile-detail-actions-extra"> <div className="mobile-detail-actions-extra">
<button
type="button"
className="secondary-button"
onClick={() => onRefreshGitMounts(selectedProfile.id)}
>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</button>
<button <button
type="button" type="button"
className="secondary-button" className="secondary-button"
+21
View File
@@ -5,6 +5,7 @@ import {
deleteConfigProfile, deleteConfigProfile,
listConfigProfiles, listConfigProfiles,
previewConfigProfile, previewConfigProfile,
refreshConfigProfileGitMounts,
updateConfigProfile, updateConfigProfile,
updateProfileIncludes, updateProfileIncludes,
type ConfigProfile, type ConfigProfile,
@@ -273,6 +274,25 @@ export const useConfigProfiles = () => {
} }
}; };
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
setError(null);
try {
const result = await refreshConfigProfileGitMounts(id);
const refreshed = result.refresh_outcomes.filter(
(outcome) => outcome.status === "refreshed",
);
setError(
refreshed.length
? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.`
: "No running instances currently use this profile's Git mounts.",
);
return true;
} catch (err) {
setError(extractErrorMessage(err));
return false;
}
};
const handlePreview = async (id: string) => { const handlePreview = async (id: string) => {
try { try {
setPreviewingId(id); setPreviewingId(id);
@@ -434,6 +454,7 @@ export const useConfigProfiles = () => {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handlePreview, handlePreview,
handleRefreshGitMounts,
updateFormField, updateFormField,
addEnvVar, addEnvVar,
updateEnvVar, updateEnvVar,
@@ -32,6 +32,7 @@ export const ConfigProfilesPage = () => {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handlePreview, handlePreview,
handleRefreshGitMounts,
updateFormField, updateFormField,
addEnvVar, addEnvVar,
updateEnvVar, updateEnvVar,
@@ -135,6 +136,7 @@ export const ConfigProfilesPage = () => {
onUpdateMountFile={updateMountFile} onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile} onRemoveMountFile={removeMountFile}
onPreview={handlePreview} onPreview={handlePreview}
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
onClosePreview={() => setPreviewData(null)} onClosePreview={() => setPreviewData(null)}
/> />
); );
@@ -176,6 +178,7 @@ export const ConfigProfilesPage = () => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
onReset={handleReset} onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)} onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
onRefreshGitMounts={() => selectedProfile && handleRefreshGitMounts(selectedProfile.id)}
onAddInclude={addInclude} onAddInclude={addInclude}
onRemoveInclude={removeInclude} onRemoveInclude={removeInclude}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@@ -0,0 +1,23 @@
# Live Git Config Mount Refresh
## Why
Git-backed Config Profile mounts are currently cloned per instance. Their content cannot be refreshed consistently for running sessions, and writable container mounts can dirty the checkout.
## Change
Move Git Config Profile mounts to profile-scoped canonical host clones. Bind the already-mounted directory sources read-only into compatible instances and refresh a stable branch/ref checkout in place under a per-clone lock.
## Scope
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
- In-place refresh for existing directory mounts only.
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
- Read-only container Git config mounts.
## Out of scope
- Writable shared Git configuration mounts.
- Global cross-user clone sharing.
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
- Atomic all-files revision switching for processes already reading the mount.
@@ -0,0 +1,30 @@
# Design: Live Git Config Mount Refresh
## Canonical source
Each selected Config Profile owns canonical Git clone directories beneath:
```text
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
```
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; clones are never shared across users.
## Runtime behavior
1. Resolve Git mounts and map them to canonical sources.
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
3. Clone into a temporary sibling, then rename on initial creation.
4. For refresh, fetch and update the existing working tree in place.
5. Bind directory mappings read-only. Existing containers see changed directory contents without recreation.
## Boundaries
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
- Refresh failure is reported without mutating a known-good checkout.
- No non-Git profile content may be copied into a Git checkout; overlapping targets are rejected or reported.
- Containers must not write to shared Git mount sources.
## Security
Host Git operations use only an authorized server-side credential source. Credentials are not part of the mounted checkout and are not exposed to containers.
@@ -0,0 +1,15 @@
# Live Git Config Mount Refresh — Tasks
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
- [x] Make Git Config Profile mounts read-only and prevent profile-content copy into Git sources.
- [x] Add lock-protected clone/fetch/ref checkout refresh that preserves a known-good checkout on failure.
- [x] Add save/refresh outcomes for live refresh, restart-required topology, and failures.
- [x] Add desktop/mobile feedback for refresh outcomes.
- [ ] Add focused resolver/service/API/frontend tests.
- [x] Run available verification and document skipped checks.
## Verification Notes
- Passed: frontend production build and Python compilation for changed backend modules.
- Skipped: backend pytest and Ruff are unavailable in this environment; Docker/manual live-session checks were not approved.
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.