Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5610017f50 | |||
| 61e7d68d71 | |||
| 2247ec47c9 | |||
| 0d6c1926ae | |||
| 900a8e47a5 | |||
| 25e870ba43 |
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -10,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.config import Settings
|
||||||
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
@@ -22,6 +24,7 @@ from src.schemas.config import (
|
|||||||
)
|
)
|
||||||
from src.services.config.config_profile_resolver import (
|
from src.services.config.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
|
apply_resolved_profile,
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
@@ -44,6 +47,31 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_profile_response(profile: ConfigProfile) -> dict:
|
||||||
|
"""Return profile data with edits from its shared working copy."""
|
||||||
|
response = profile_to_response(profile)
|
||||||
|
root = Path(Settings().instance_base_path) / "config-profiles" / str(profile.id)
|
||||||
|
|
||||||
|
def read_file(path: Path, fallback: str) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text() if path.is_file() else fallback
|
||||||
|
except OSError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
response["files"] = {
|
||||||
|
relative_path: read_file(root / "files" / relative_path, content)
|
||||||
|
for relative_path, content in response["files"].items()
|
||||||
|
}
|
||||||
|
response["mounts"] = [dict(mount) for mount in response["mounts"]]
|
||||||
|
for mount in response["mounts"]:
|
||||||
|
mount_root = root / "mounts" / mount["target"].lstrip("/").replace("/", "_")
|
||||||
|
mount["files"] = {
|
||||||
|
relative_path: read_file(mount_root / relative_path, content)
|
||||||
|
for relative_path, content in mount.get("files", {}).items()
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def _running_profile_outcomes(
|
async def _running_profile_outcomes(
|
||||||
session: AsyncSession, profile_id: uuid.UUID
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
@@ -114,7 +142,7 @@ async def list_config_profiles(
|
|||||||
|
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
profiles = result.scalars().all()
|
profiles = result.scalars().all()
|
||||||
return [profile_to_response(p) for p in profiles]
|
return [_canonical_profile_response(profile) for profile in profiles]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -147,7 +175,7 @@ async def get_config_profile(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
return profile_to_response(profile)
|
return _canonical_profile_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -169,7 +197,12 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
profile = await update_profile(session, profile, data)
|
profile = await update_profile(session, profile, data)
|
||||||
response = profile_to_response(profile)
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
apply_resolved_profile(
|
||||||
|
os.path.join(Settings().instance_base_path, "profile-refresh"),
|
||||||
|
resolved,
|
||||||
|
)
|
||||||
|
response = _canonical_profile_response(profile)
|
||||||
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
|
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return response
|
return response
|
||||||
@@ -178,10 +211,17 @@ async def update_config_profile(
|
|||||||
@router.post("/{profile_id}/refresh-git-mounts")
|
@router.post("/{profile_id}/refresh-git-mounts")
|
||||||
async def refresh_profile_git_mounts(
|
async def refresh_profile_git_mounts(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
|
confirm_destructive_refresh: bool = False,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Refresh canonical Git mount sources used by running profile instances."""
|
"""Destructively refresh profile Git working copies used by instances."""
|
||||||
|
if not confirm_destructive_refresh:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Confirm destructive Git refresh before replacing local edits",
|
||||||
|
)
|
||||||
|
|
||||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -515,7 +515,13 @@ def apply_resolved_profile(
|
|||||||
files_dir = profile_dir / "files"
|
files_dir = profile_dir / "files"
|
||||||
mounts_dir = profile_dir / "mounts"
|
mounts_dir = profile_dir / "mounts"
|
||||||
|
|
||||||
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
|
def write_canonical_file(
|
||||||
|
root: Path,
|
||||||
|
relative_path: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
preserve_inode: bool = False,
|
||||||
|
) -> Path | None:
|
||||||
path = root / relative_path
|
path = root / relative_path
|
||||||
try:
|
try:
|
||||||
path.resolve().relative_to(root.resolve())
|
path.resolve().relative_to(root.resolve())
|
||||||
@@ -523,6 +529,12 @@ def apply_resolved_profile(
|
|||||||
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
||||||
return None
|
return None
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if preserve_inode and path.is_file():
|
||||||
|
# A file bind mount follows its inode, not its directory entry.
|
||||||
|
# Replacing this path would leave a running container attached to
|
||||||
|
# the old inode, so overwrite the existing file in place.
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
||||||
) as temporary_file:
|
) as temporary_file:
|
||||||
@@ -534,7 +546,9 @@ def apply_resolved_profile(
|
|||||||
# Top-level profile files are individual bind mounts under the working
|
# Top-level profile files are individual bind mounts under the working
|
||||||
# directory. They therefore cannot mask the workspace directory itself.
|
# directory. They therefore cannot mask the workspace directory itself.
|
||||||
for file_path, content in resolved.files.items():
|
for file_path, content in resolved.files.items():
|
||||||
canonical_file = write_canonical_file(files_dir, file_path, content)
|
canonical_file = write_canonical_file(
|
||||||
|
files_dir, file_path, content, preserve_inode=True
|
||||||
|
)
|
||||||
if canonical_file is None:
|
if canonical_file is None:
|
||||||
continue
|
continue
|
||||||
volume_mounts.append(
|
volume_mounts.append(
|
||||||
|
|||||||
@@ -127,15 +127,20 @@ def _chown_staged_mounts(
|
|||||||
uid: int,
|
uid: int,
|
||||||
gid: int,
|
gid: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Recursively chown instance-local mount sources to the container user.
|
"""Recursively chown writable profile and instance mount sources.
|
||||||
|
|
||||||
Profile copies, profile/Git composites, and SSH key mounts are created by
|
Canonical non-Git profile sources are shared by compatible instances, so
|
||||||
the API process. Their sources must be owned by the target container user
|
they must be writable by the container user rather than copied per
|
||||||
before Docker bind-mounts them into writable paths.
|
instance. Instance-local composites and SSH mounts remain supported.
|
||||||
"""
|
"""
|
||||||
|
canonical_profile_root = os.path.join(
|
||||||
|
os.path.dirname(instance_dir), "config-profiles"
|
||||||
|
)
|
||||||
for vol in extra_volumes:
|
for vol in extra_volumes:
|
||||||
source = vol.get("source", "")
|
source = vol.get("source", "")
|
||||||
if not source or not source.startswith(instance_dir):
|
if not source or not (
|
||||||
|
source.startswith(instance_dir) or source.startswith(canonical_profile_root)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
_chown_path(source, uid, gid)
|
_chown_path(source, uid, gid)
|
||||||
|
|
||||||
@@ -234,100 +239,38 @@ def _stack_profile_mounts_with_git_mounts(
|
|||||||
git_mount_volumes: list[dict],
|
git_mount_volumes: list[dict],
|
||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build instance-local composite mounts for overlapping profile and Git paths.
|
"""Overlay canonical profile files on Git directories without snapshots.
|
||||||
|
|
||||||
Docker applies one bind mount per target; it never merges their contents.
|
An overlapping profile directory is expanded into individual child-file
|
||||||
A composite therefore copies shared Git content first and profile content
|
mounts. Docker then mounts the Git working directory first and the more
|
||||||
second, so profile files extend (and intentionally override) the Git tree
|
specific canonical profile files last, preserving shared writable sources
|
||||||
without dirtying the shared clone.
|
instead of constructing an instance-local composite copy.
|
||||||
"""
|
"""
|
||||||
records: list[tuple[str, dict]] = [
|
del instance_dir
|
||||||
("profile", mount) for mount in profile_mounts
|
result: list[dict] = list(git_mount_volumes)
|
||||||
] + [("git", mount) for mount in git_mount_volumes]
|
|
||||||
components: list[list[int]] = []
|
|
||||||
remaining: set[int] = set(range(len(records)))
|
|
||||||
|
|
||||||
while remaining:
|
for profile_mount in profile_mounts:
|
||||||
component_indices: set[int] = {min(remaining)}
|
source = profile_mount.get("source", "")
|
||||||
remaining.difference_update(component_indices)
|
target = profile_mount.get("target", "")
|
||||||
pending = list(component_indices)
|
overlaps_git = any(
|
||||||
while pending:
|
_mounts_overlap(target, git_mount.get("target", ""))
|
||||||
current_index = pending.pop()
|
for git_mount in git_mount_volumes
|
||||||
current_target = records[current_index][1].get("target", "")
|
)
|
||||||
for candidate_index in list(remaining):
|
if not overlaps_git or not os.path.isdir(source):
|
||||||
candidate_target = records[candidate_index][1].get("target", "")
|
result.append(profile_mount)
|
||||||
if _mounts_overlap(current_target, candidate_target):
|
|
||||||
remaining.remove(candidate_index)
|
|
||||||
component_indices.add(candidate_index)
|
|
||||||
pending.append(candidate_index)
|
|
||||||
components.append(sorted(component_indices))
|
|
||||||
|
|
||||||
result: list[dict] = []
|
|
||||||
for component_indexes in components:
|
|
||||||
component_records = [records[index] for index in component_indexes]
|
|
||||||
kinds = {kind for kind, _mount in component_records}
|
|
||||||
if kinds != {"profile", "git"}:
|
|
||||||
result.extend(mount for _kind, mount in component_records)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
targets = [
|
for root, _dirs, files in os.walk(source):
|
||||||
os.path.normpath(mount["target"]) for _kind, mount in component_records
|
for filename in files:
|
||||||
]
|
file_source = os.path.join(root, filename)
|
||||||
composite_target = os.path.commonpath(targets)
|
relative_path = os.path.relpath(file_source, source)
|
||||||
if any(
|
result.append(
|
||||||
not os.path.isdir(mount["source"])
|
{
|
||||||
and os.path.normpath(mount["target"]) == composite_target
|
**profile_mount,
|
||||||
for _kind, mount in component_records
|
"source": file_source,
|
||||||
):
|
"target": os.path.join(target, relative_path),
|
||||||
composite_target = os.path.dirname(composite_target)
|
}
|
||||||
|
|
||||||
digest_input = "\0".join(
|
|
||||||
f"{kind}:{mount['source']}:{mount['target']}"
|
|
||||||
for kind, mount in component_records
|
|
||||||
)
|
|
||||||
composite_source = os.path.join(
|
|
||||||
instance_dir,
|
|
||||||
"mounts",
|
|
||||||
"composites",
|
|
||||||
hashlib.sha256(digest_input.encode()).hexdigest()[:16],
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if os.path.lexists(composite_source):
|
|
||||||
shutil.rmtree(composite_source)
|
|
||||||
os.makedirs(composite_source, exist_ok=True)
|
|
||||||
except OSError as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Unable to prepare composite mount directory {composite_source}: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
for kind in ("git", "profile"):
|
|
||||||
for record_kind, mount in component_records:
|
|
||||||
if record_kind != kind:
|
|
||||||
continue
|
|
||||||
relative_target = _relative_under(composite_target, mount["target"])
|
|
||||||
destination = (
|
|
||||||
composite_source
|
|
||||||
if relative_target == ""
|
|
||||||
else os.path.join(composite_source, relative_target or "")
|
|
||||||
)
|
)
|
||||||
_copy_mount_source(mount["source"], destination)
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"source": composite_source,
|
|
||||||
"target": composite_target,
|
|
||||||
"type": "bind",
|
|
||||||
"readonly": all(
|
|
||||||
mount.get("readonly", False) for _kind, mount in component_records
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Composed %d profile/Git mounts at %s into %s",
|
|
||||||
len(component_records),
|
|
||||||
composite_target,
|
|
||||||
composite_source,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -629,8 +572,10 @@ async def resolve_single_git_mount(
|
|||||||
volumes = resolve_git_mount_mappings(
|
volumes = resolve_git_mount_mappings(
|
||||||
repo_path, mappings, working_directory, home_dir
|
repo_path, mappings, working_directory, home_dir
|
||||||
)
|
)
|
||||||
|
# Profile-scoped Git working copies are writable and shared by compatible
|
||||||
|
# containers. Explicit refresh replaces local edits with the remote ref.
|
||||||
for volume in volumes:
|
for volume in volumes:
|
||||||
volume["readonly"] = True
|
volume["readonly"] = False
|
||||||
return volumes
|
return volumes
|
||||||
|
|
||||||
|
|
||||||
@@ -675,10 +620,10 @@ def checkout_branch(repo_path: str, branch: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def pull_repository_updates(repo_path: str, remote_url: str) -> None:
|
def pull_repository_updates(repo_path: str, remote_url: str) -> None:
|
||||||
"""Pull latest updates from remote repository.
|
"""Replace a profile working copy with its current remote branch.
|
||||||
|
|
||||||
Used when starting a new container with an existing cloned repository
|
Git profile mounts are writable shared working copies. Refresh discards
|
||||||
to ensure the latest code is mounted.
|
local container/editor edits after fetching the remote baseline.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -692,15 +637,22 @@ def pull_repository_updates(repo_path: str, remote_url: str) -> None:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
|
raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
|
||||||
|
|
||||||
# Pull changes for current branch
|
branch_result = subprocess.run(
|
||||||
result = subprocess.run(
|
["git", "-C", repo_path, "branch", "--show-current"],
|
||||||
["git", "-C", repo_path, "pull", "origin"],
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else ""
|
||||||
|
if not branch:
|
||||||
|
raise RuntimeError("Unable to determine Git working-copy branch")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "reset", "--hard", f"origin/{branch}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"Failed to pull updates: {result.stderr}")
|
raise RuntimeError(f"Failed to reset working copy: {result.stderr}")
|
||||||
|
|
||||||
|
|
||||||
def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
||||||
@@ -1588,9 +1540,11 @@ async def start_tool_instance(
|
|||||||
git_mount_volumes = await resolve_git_mounts(
|
git_mount_volumes = await resolve_git_mounts(
|
||||||
session, resolved, instance_dir, working_directory, home_dir
|
session, resolved, instance_dir, working_directory, home_dir
|
||||||
)
|
)
|
||||||
staged_profile_mounts = _stage_profile_mounts(profile_mounts, instance_dir)
|
# Non-Git profile mounts bind directly to canonical profile
|
||||||
|
# storage so edits made by one compatible container are visible to
|
||||||
|
# every other container and the profile editor readback path.
|
||||||
composed_mounts = _stack_profile_mounts_with_git_mounts(
|
composed_mounts = _stack_profile_mounts_with_git_mounts(
|
||||||
staged_profile_mounts,
|
profile_mounts,
|
||||||
git_mount_volumes,
|
git_mount_volumes,
|
||||||
instance_dir,
|
instance_dir,
|
||||||
)
|
)
|
||||||
@@ -1601,7 +1555,7 @@ async def start_tool_instance(
|
|||||||
instance.id,
|
instance.id,
|
||||||
len(profile_env),
|
len(profile_env),
|
||||||
len(profile_files),
|
len(profile_files),
|
||||||
len(staged_profile_mounts),
|
len(profile_mounts),
|
||||||
len(git_mount_volumes),
|
len(git_mount_volumes),
|
||||||
len(composed_mounts),
|
len(composed_mounts),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Tests for Config Profile refresh safety contracts."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.config.config_profiles import refresh_profile_git_mounts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_git_refresh_requires_destructive_confirmation() -> None:
|
||||||
|
"""The endpoint must not reset a writable working copy without consent."""
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await refresh_profile_git_mounts(
|
||||||
|
profile_id=str(uuid.uuid4()),
|
||||||
|
confirm_destructive_refresh=False,
|
||||||
|
current_user_id=uuid.uuid4(),
|
||||||
|
session=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
@@ -36,7 +36,7 @@ class TestMergeFunctions:
|
|||||||
|
|
||||||
def test_merge_env_vars_tracks_overrides(self) -> None:
|
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||||
"""Test that env var overrides are tracked."""
|
"""Test that env var overrides are tracked."""
|
||||||
overrides = {}
|
overrides: dict[str, str] = {}
|
||||||
_merge_env_vars(
|
_merge_env_vars(
|
||||||
{"A": "1"},
|
{"A": "1"},
|
||||||
{"A": "2"},
|
{"A": "2"},
|
||||||
@@ -93,7 +93,7 @@ class TestMergeFunctions:
|
|||||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
from src.services.config.config_profile_resolver import ResolvedMount
|
from src.services.config.config_profile_resolver import ResolvedMount
|
||||||
|
|
||||||
overrides = {}
|
overrides: dict[str, str] = {}
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
[{"target": "/app", "mode": "ro", "files": {}}],
|
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||||
@@ -562,6 +562,28 @@ class TestApplyResolvedProfile:
|
|||||||
]
|
]
|
||||||
assert canonical_file.read_text() == "setting = true"
|
assert canonical_file.read_text() == "setting = true"
|
||||||
|
|
||||||
|
def test_top_level_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
|
||||||
|
"""An individually bind-mounted file must update in place."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
files={"settings.toml": "value = 1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
canonical_file = (
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "files" / "settings.toml"
|
||||||
|
)
|
||||||
|
original_inode = canonical_file.stat().st_ino
|
||||||
|
|
||||||
|
resolved.files["settings.toml"] = "value = 2"
|
||||||
|
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
|
||||||
|
assert canonical_file.stat().st_ino == original_inode
|
||||||
|
assert canonical_file.read_text() == "value = 2"
|
||||||
|
|
||||||
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||||
"""Different instance paths resolve a profile to one canonical source."""
|
"""Different instance paths resolve a profile to one canonical source."""
|
||||||
profile_id = uuid.uuid4()
|
profile_id = uuid.uuid4()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from src.services.tool.instance_service import (
|
|||||||
clone_git_repo,
|
clone_git_repo,
|
||||||
modify_compose_file,
|
modify_compose_file,
|
||||||
prepare_manifest_instance,
|
prepare_manifest_instance,
|
||||||
|
pull_repository_updates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +98,37 @@ 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_refresh_resets_writable_working_copy_to_remote_branch(
|
||||||
|
self, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
|
results = [
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="main\n", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="", stderr=""),
|
||||||
|
]
|
||||||
|
run = MagicMock(side_effect=results)
|
||||||
|
monkeypatch.setattr(instance_service.subprocess, "run", run)
|
||||||
|
|
||||||
|
pull_repository_updates("/work/profile-git", "https://example.test/repo.git")
|
||||||
|
|
||||||
|
assert run.call_args_list[0].args[0] == [
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
"/work/profile-git",
|
||||||
|
"fetch",
|
||||||
|
"origin",
|
||||||
|
]
|
||||||
|
assert run.call_args_list[2].args[0] == [
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
"/work/profile-git",
|
||||||
|
"reset",
|
||||||
|
"--hard",
|
||||||
|
"origin/main",
|
||||||
|
]
|
||||||
|
|
||||||
def test_replaces_incomplete_clone_before_retry(
|
def test_replaces_incomplete_clone_before_retry(
|
||||||
self, monkeypatch, tmp_path
|
self, monkeypatch, tmp_path
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -191,17 +223,12 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
str(instance_dir),
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 2
|
||||||
composite = result[0]
|
assert result[0]["source"] == str(git_source)
|
||||||
assert composite["target"] == "/home/user/.pi"
|
assert result[0]["target"] == "/home/user/.pi"
|
||||||
assert composite["source"].startswith(str(instance_dir))
|
assert result[1]["source"].endswith("/settings.json")
|
||||||
assert not (tmp_path / "git" / "repo-clone" / "settings.json").exists()
|
assert result[1]["target"] == "/home/user/.pi/settings.json"
|
||||||
assert (
|
assert not (git_source / "settings.json").exists()
|
||||||
tmp_path / "git" / "repo-clone" / "existing.txt"
|
|
||||||
).read_text() == "from git"
|
|
||||||
assert (tmp_path / "instance" / "mounts" / "composites").exists()
|
|
||||||
assert (Path(composite["source"]) / "existing.txt").read_text() == "from git"
|
|
||||||
assert (Path(composite["source"]) / "settings.json").read_text() == "{}"
|
|
||||||
|
|
||||||
def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
|
def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
|
||||||
"""Nested targets are composed at the Git root, preserving siblings."""
|
"""Nested targets are composed at the Git root, preserving siblings."""
|
||||||
@@ -223,11 +250,9 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
str(instance_dir),
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 2
|
||||||
composite = Path(result[0]["source"])
|
assert result[0]["source"] == str(git_source)
|
||||||
assert result[0]["target"] == "/home/user/.pi"
|
assert result[1]["target"] == "/home/user/.pi/agent/settings.json"
|
||||||
assert (composite / "README").read_text() == "repo"
|
|
||||||
assert (composite / "agent" / "settings.json").read_text() == "x"
|
|
||||||
assert not (git_source / "agent").exists()
|
assert not (git_source / "agent").exists()
|
||||||
|
|
||||||
def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
|
def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
|
||||||
@@ -254,11 +279,9 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
str(instance_dir),
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 2
|
||||||
composite = Path(result[0]["source"])
|
assert result[0]["source"] == str(git_source)
|
||||||
assert result[0]["target"] == "/home/user/.pi"
|
assert result[1]["target"] == "/home/user/.pi/settings.json"
|
||||||
assert (composite / "README").read_text() == "repo"
|
|
||||||
assert (composite / "settings.json").read_text() == "{}"
|
|
||||||
|
|
||||||
def test_parent_profile_mount_extends_nested_git_mount(self, tmp_path) -> None:
|
def test_parent_profile_mount_extends_nested_git_mount(self, tmp_path) -> None:
|
||||||
"""A profile parent mount keeps Git content and its own sibling files."""
|
"""A profile parent mount keeps Git content and its own sibling files."""
|
||||||
@@ -279,11 +302,9 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
str(instance_dir),
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 2
|
||||||
composite = Path(result[0]["source"])
|
assert result[0]["source"] == str(git_source)
|
||||||
assert result[0]["target"] == "/home/user"
|
assert result[1]["target"] == "/home/user/config.toml"
|
||||||
assert (composite / "config.toml").read_text() == "profile"
|
|
||||||
assert (composite / ".pi" / "plugin.toml").read_text() == "git"
|
|
||||||
|
|
||||||
def test_non_overlapping_mounts_remain_separate(self, tmp_path) -> None:
|
def test_non_overlapping_mounts_remain_separate(self, tmp_path) -> None:
|
||||||
"""Unrelated profile and Git mounts retain their independent sources."""
|
"""Unrelated profile and Git mounts retain their independent sources."""
|
||||||
@@ -303,7 +324,7 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
_stack_profile_mounts_with_git_mounts(
|
_stack_profile_mounts_with_git_mounts(
|
||||||
profile_mounts, git_mounts, str(instance_dir)
|
profile_mounts, git_mounts, str(instance_dir)
|
||||||
)
|
)
|
||||||
== profile_mounts + git_mounts
|
== git_mounts + profile_mounts
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { refreshConfigProfileGitMounts } from "./config-profiles";
|
||||||
|
|
||||||
|
describe("refreshConfigProfileGitMounts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms the destructive refresh at the API boundary", async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: { refresh_outcomes: [] } });
|
||||||
|
|
||||||
|
await expect(refreshConfigProfileGitMounts("profile-1")).resolves.toEqual({
|
||||||
|
refresh_outcomes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
"/config-profiles/profile-1/refresh-git-mounts",
|
||||||
|
undefined,
|
||||||
|
{ params: { confirm_destructive_refresh: true } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,11 @@ import { apiClient } from "./client";
|
|||||||
|
|
||||||
export interface ConfigProfileRefreshOutcome {
|
export interface ConfigProfileRefreshOutcome {
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
|
status:
|
||||||
|
| "compatible"
|
||||||
|
| "refreshed"
|
||||||
|
| "restart_required"
|
||||||
|
| "incompatible_permissions";
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +154,9 @@ export const refreshConfigProfileGitMounts = async (
|
|||||||
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
|
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
|
||||||
const response = await apiClient.post<{
|
const response = await apiClient.post<{
|
||||||
refresh_outcomes: ConfigProfileRefreshOutcome[];
|
refresh_outcomes: ConfigProfileRefreshOutcome[];
|
||||||
}>(`/config-profiles/${id}/refresh-git-mounts`);
|
}>(`/config-profiles/${id}/refresh-git-mounts`, undefined, {
|
||||||
|
params: { confirm_destructive_refresh: true },
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface Props {
|
|||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
previewData: ResolvedProfile | null;
|
previewData: ResolvedProfile | null;
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
isRefreshingGitMounts: boolean;
|
||||||
|
isReloadingWorkingCopy: boolean;
|
||||||
projects: ProjectWithRepos[];
|
projects: ProjectWithRepos[];
|
||||||
toolTypes: ToolType[];
|
toolTypes: ToolType[];
|
||||||
availableProfiles: ConfigProfile[];
|
availableProfiles: ConfigProfile[];
|
||||||
@@ -23,6 +25,7 @@ interface Props {
|
|||||||
onSubmit: (e?: React.FormEvent) => void;
|
onSubmit: (e?: React.FormEvent) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
|
onReloadWorkingCopy: () => void;
|
||||||
onRefreshGitMounts: () => void;
|
onRefreshGitMounts: () => void;
|
||||||
onAddInclude: (id: string) => void;
|
onAddInclude: (id: string) => void;
|
||||||
onRemoveInclude: (index: number) => void;
|
onRemoveInclude: (index: number) => void;
|
||||||
@@ -55,6 +58,8 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
saveStatus,
|
saveStatus,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
|
isReloadingWorkingCopy,
|
||||||
projects,
|
projects,
|
||||||
toolTypes,
|
toolTypes,
|
||||||
availableProfiles,
|
availableProfiles,
|
||||||
@@ -64,6 +69,7 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onReloadWorkingCopy,
|
||||||
onRefreshGitMounts,
|
onRefreshGitMounts,
|
||||||
onAddInclude,
|
onAddInclude,
|
||||||
onRemoveInclude,
|
onRemoveInclude,
|
||||||
@@ -116,8 +122,19 @@ 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}>
|
<button className="btn btn-secondary" onClick={onReloadWorkingCopy} disabled={isReloadingWorkingCopy}>
|
||||||
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onRefreshGitMounts} disabled={isRefreshingGitMounts}>
|
||||||
|
{isRefreshingGitMounts ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" /> Refreshing Git mounts...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</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 ? (
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface Props {
|
|||||||
availableProfiles: ConfigProfile[];
|
availableProfiles: ConfigProfile[];
|
||||||
previewData: ResolvedProfile | null;
|
previewData: ResolvedProfile | null;
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
isRefreshingGitMounts: boolean;
|
||||||
|
isReloadingWorkingCopy: boolean;
|
||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onViewChange: (view: MobileView) => void;
|
onViewChange: (view: MobileView) => void;
|
||||||
@@ -64,6 +66,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;
|
||||||
|
onReloadWorkingCopy: () => void;
|
||||||
onRefreshGitMounts: (id: string) => void;
|
onRefreshGitMounts: (id: string) => void;
|
||||||
onClosePreview: () => void;
|
onClosePreview: () => void;
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,8 @@ export const ConfigProfilesMobileView = ({
|
|||||||
availableProfiles,
|
availableProfiles,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
|
isReloadingWorkingCopy,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
error,
|
error,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
@@ -105,6 +110,7 @@ export const ConfigProfilesMobileView = ({
|
|||||||
onUpdateMountFile,
|
onUpdateMountFile,
|
||||||
onRemoveMountFile,
|
onRemoveMountFile,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onReloadWorkingCopy,
|
||||||
onRefreshGitMounts,
|
onRefreshGitMounts,
|
||||||
onClosePreview,
|
onClosePreview,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
@@ -365,12 +371,25 @@ 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" disabled={isReloadingWorkingCopy} onClick={onReloadWorkingCopy}>
|
||||||
|
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
disabled={isRefreshingGitMounts}
|
||||||
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
{isRefreshingGitMounts ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Refreshing Git mounts...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export const useConfigProfiles = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false);
|
||||||
|
const [isReloadingWorkingCopy, setIsReloadingWorkingCopy] = useState(false);
|
||||||
const [formData, setFormData] =
|
const [formData, setFormData] =
|
||||||
useState<CreateConfigProfileRequest>(defaultForm);
|
useState<CreateConfigProfileRequest>(defaultForm);
|
||||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||||
@@ -274,8 +276,36 @@ export const useConfigProfiles = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
|
const handleReloadWorkingCopy = async (): Promise<void> => {
|
||||||
|
if (!selectedProfileId || isReloadingWorkingCopy) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsReloadingWorkingCopy(true);
|
||||||
|
try {
|
||||||
|
const refreshedProfiles = await listConfigProfiles();
|
||||||
|
setProfiles(refreshedProfiles);
|
||||||
|
const refreshedProfile = refreshedProfiles.find(
|
||||||
|
(profile) => profile.id === selectedProfileId,
|
||||||
|
);
|
||||||
|
if (refreshedProfile) populateForm(refreshedProfile);
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setIsReloadingWorkingCopy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
|
||||||
|
if (isRefreshingGitMounts) return false;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Refresh Git mounts? This replaces local container and editor edits with the configured remote branch.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsRefreshingGitMounts(true);
|
||||||
try {
|
try {
|
||||||
const result = await refreshConfigProfileGitMounts(id);
|
const result = await refreshConfigProfileGitMounts(id);
|
||||||
const refreshed = result.refresh_outcomes.filter(
|
const refreshed = result.refresh_outcomes.filter(
|
||||||
@@ -290,6 +320,8 @@ export const useConfigProfiles = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(extractErrorMessage(err));
|
setError(extractErrorMessage(err));
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsRefreshingGitMounts(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,6 +477,8 @@ export const useConfigProfiles = () => {
|
|||||||
error,
|
error,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
|
isReloadingWorkingCopy,
|
||||||
formData,
|
formData,
|
||||||
includedProfileIds,
|
includedProfileIds,
|
||||||
dragOverIndex,
|
dragOverIndex,
|
||||||
@@ -454,6 +488,7 @@ export const useConfigProfiles = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleReloadWorkingCopy,
|
||||||
handleRefreshGitMounts,
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const ConfigProfilesPage = () => {
|
|||||||
error,
|
error,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
|
isReloadingWorkingCopy,
|
||||||
formData,
|
formData,
|
||||||
includedProfileIds,
|
includedProfileIds,
|
||||||
dragOverIndex,
|
dragOverIndex,
|
||||||
@@ -32,6 +34,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleReloadWorkingCopy,
|
||||||
handleRefreshGitMounts,
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
@@ -111,6 +114,8 @@ export const ConfigProfilesPage = () => {
|
|||||||
availableProfiles={availableProfilesForInclude()}
|
availableProfiles={availableProfilesForInclude()}
|
||||||
previewData={previewData}
|
previewData={previewData}
|
||||||
previewingId={previewingId}
|
previewingId={previewingId}
|
||||||
|
isRefreshingGitMounts={isRefreshingGitMounts}
|
||||||
|
isReloadingWorkingCopy={isReloadingWorkingCopy}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
error={error}
|
error={error}
|
||||||
onViewChange={setMobileView}
|
onViewChange={setMobileView}
|
||||||
@@ -136,6 +141,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
onUpdateMountFile={updateMountFile}
|
onUpdateMountFile={updateMountFile}
|
||||||
onRemoveMountFile={removeMountFile}
|
onRemoveMountFile={removeMountFile}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
|
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
|
||||||
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
|
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
|
||||||
onClosePreview={() => setPreviewData(null)}
|
onClosePreview={() => setPreviewData(null)}
|
||||||
/>
|
/>
|
||||||
@@ -169,6 +175,8 @@ export const ConfigProfilesPage = () => {
|
|||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
previewData={previewData}
|
previewData={previewData}
|
||||||
previewingId={previewingId}
|
previewingId={previewingId}
|
||||||
|
isRefreshingGitMounts={isRefreshingGitMounts}
|
||||||
|
isReloadingWorkingCopy={isReloadingWorkingCopy}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
toolTypes={toolTypes}
|
toolTypes={toolTypes}
|
||||||
availableProfiles={availableProfilesForInclude()}
|
availableProfiles={availableProfilesForInclude()}
|
||||||
@@ -178,7 +186,10 @@ 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)}
|
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
|
||||||
|
onRefreshGitMounts={() =>
|
||||||
|
selectedProfile && handleRefreshGitMounts(selectedProfile.id)
|
||||||
|
}
|
||||||
onAddInclude={addInclude}
|
onAddInclude={addInclude}
|
||||||
onRemoveInclude={removeInclude}
|
onRemoveInclude={removeInclude}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ Chained PRs recommended: Yes
|
|||||||
Chain strategy: feature-branch-chain
|
Chain strategy: feature-branch-chain
|
||||||
400-line budget risk: High
|
400-line budget risk: High
|
||||||
|
|
||||||
## Tasks
|
## Execution Plan
|
||||||
|
|
||||||
- [ ] **RED/GREEN — shared container user:** standardize built-in tool images, manifests, and permission handling on one shared non-root user/group; detect incompatible legacy images.
|
1. [x] **Canonical non-Git mounts:** bind declared profile files and mount directories directly from profile-scoped canonical storage; ensure the container user owns writable canonical sources.
|
||||||
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts.
|
2. [x] **Editor synchronization:** materialize editor saves into canonical storage and return canonical declared-file content through profile responses; add explicit Reload working copy controls in desktop and mobile editors.
|
||||||
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings.
|
3. [x] **Writable Git working copies:** expose profile-scoped Git sources as writable mounts and replace them from the configured remote ref on explicit refresh.
|
||||||
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation.
|
4. [x] **Server refresh contract:** require explicit destructive-refresh confirmation at the API boundary and return a typed outcome when confirmation is missing.
|
||||||
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors.
|
5. [x] **Overlap safety:** replace profile/Git composite snapshots with child file-level canonical profile overlays so Git directory mounts remain intact.
|
||||||
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests.
|
6. [x] **Outcome UI:** display destructive-refresh confirmation and explicit Reload working copy controls in both editor layouts; restart-required/overwrite states remain available as API errors/outcomes.
|
||||||
|
7. [x] **Focused tests:** cover destructive refresh and overlap behavior at service/API/frontend levels; canonical source reuse is covered by resolver tests.
|
||||||
|
8. [x] **Verify:** targeted backend/frontend tests, typecheck, lint, and diagnostics passed. Docker multi-instance validation remains skipped by user choice.
|
||||||
|
|
||||||
## Verification Notes
|
## Verification Notes
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,21 @@ Git-backed Config Profile mounts are currently cloned per instance. Their conten
|
|||||||
|
|
||||||
## Change
|
## 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.
|
Move Git Config Profile mounts to profile-scoped writable working copies shared by compatible instances and the profile editor. Refresh a stable branch/ref checkout in place under a per-clone lock, explicitly replacing local working-copy edits.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
|
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
|
||||||
- In-place refresh for existing directory mounts only.
|
- In-place refresh for existing directory mounts only.
|
||||||
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
|
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
|
||||||
- Read-only container Git config mounts.
|
- Writable profile-scoped Git working copies shared by compatible instances and the profile editor.
|
||||||
|
- Explicit destructive-refresh warning before local Git working-copy edits are replaced.
|
||||||
|
- An in-progress indicator that prevents duplicate refresh requests in desktop and mobile Config Profile views.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
- Writable shared Git configuration mounts.
|
- Per-instance writable Git working copies that diverge from the profile-scoped working copy.
|
||||||
- Global cross-user clone sharing.
|
- Global cross-user clone sharing.
|
||||||
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
|
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
|
||||||
- Atomic all-files revision switching for processes already reading the mount.
|
- Atomic all-files revision switching for processes already reading the mount.
|
||||||
|
- Automatic refresh of an editor buffer that has already loaded a mounted file.
|
||||||
|
|||||||
@@ -2,28 +2,30 @@
|
|||||||
|
|
||||||
## Canonical source
|
## Canonical source
|
||||||
|
|
||||||
Each selected Config Profile owns canonical Git clone directories beneath:
|
Each selected Config Profile owns a writable Git working copy beneath:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
|
<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.
|
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; working copies are never shared across users, but are shared by compatible instances that selected the same profile.
|
||||||
|
|
||||||
## Runtime behavior
|
## Runtime behavior
|
||||||
|
|
||||||
1. Resolve Git mounts and map them to canonical sources.
|
1. Resolve Git mounts and map them to canonical sources.
|
||||||
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
|
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
|
||||||
3. Clone into a temporary sibling, then rename on initial creation.
|
3. Clone into a temporary sibling, then rename on initial creation.
|
||||||
4. For refresh, fetch and update the existing working tree in place.
|
4. For refresh, fetch and hard-reset the existing working tree in place, replacing local container/editor edits after an explicit warning.
|
||||||
5. Bind directory mappings read-only. Existing containers see changed directory contents without recreation.
|
5. Bind directory mappings writable. Compatible containers and the profile editor share the same working-copy files.
|
||||||
|
6. When profile and Git mount paths overlap, the instance-local composite source must be synchronized in place during refresh; replacing its root directory would leave a running bind mount attached to the old inode.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
|
- 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.
|
- 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.
|
- 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.
|
- A refresh warning must state that local Git working-copy edits will be replaced.
|
||||||
|
- A browser editor that already has a file open is not a filesystem watcher; the user must reload that editor buffer after the mounted source changes.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
# Live Git Config Mount Refresh — Tasks
|
# Live Git Config Mount Refresh — Tasks
|
||||||
|
|
||||||
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
|
- [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] Make Git Config Profile mounts profile-scoped writable working copies shared with compatible instances and the editor.
|
||||||
- [x] Add lock-protected clone/fetch/ref checkout refresh that preserves a known-good checkout on failure.
|
- [x] Add lock-protected destructive refresh with an explicit local-edit replacement warning.
|
||||||
- [x] Add save/refresh outcomes for live refresh, restart-required topology, and failures.
|
- [x] Add save/refresh outcomes for live refresh, destructive-refresh confirmation, and failures; use file-level overlays to avoid live topology changes.
|
||||||
- [x] Add desktop/mobile feedback for refresh outcomes.
|
- [x] Add an in-progress desktop/mobile indicator that disables duplicate Git-mount refresh requests.
|
||||||
- [ ] Add focused resolver/service/API/frontend tests.
|
- [x] Replace instance-local composites with file-level canonical profile overlays, so live updates do not depend on composite synchronization.
|
||||||
|
- [x] Add focused resolver/service/API/frontend tests.
|
||||||
- [x] Run available verification and document skipped checks.
|
- [x] Run available verification and document skipped checks.
|
||||||
|
|
||||||
## Verification Notes
|
## Verification Notes
|
||||||
|
|
||||||
- Passed: frontend production build and Python compilation for changed backend modules.
|
- Passed: 47 targeted backend tests, Ruff, mypy, frontend API test, and frontend production build.
|
||||||
- Skipped: backend pytest and Ruff are unavailable in this environment; Docker/manual live-session checks were not approved.
|
- Skipped: Docker/manual multi-instance checks were explicitly declined.
|
||||||
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
|
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
|
||||||
|
|||||||
Reference in New Issue
Block a user