Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886c863260 | |||
| 3c25fffd49 | |||
| add7c1b500 | |||
| f9f9372ee7 | |||
| ea42165ed2 | |||
| c178225c8b | |||
| b5e961ebe9 | |||
| 5d379c5f8b | |||
| 671540ded9 | |||
| ad26fd9f35 | |||
| bb38b37ceb | |||
| 6698c20f25 |
@@ -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
|
||||||
@@ -9,7 +10,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.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
ConfigProfileIncludeUpdate,
|
ConfigProfileIncludeUpdate,
|
||||||
@@ -33,6 +34,7 @@ from src.services.config.crud_service import (
|
|||||||
update_profile,
|
update_profile,
|
||||||
validate_default_profiles,
|
validate_default_profiles,
|
||||||
)
|
)
|
||||||
|
from src.services.tool.instance_service import resolve_git_mounts
|
||||||
from src.services.config.resolver_service import (
|
from src.services.config.resolver_service import (
|
||||||
resolve_default_profile,
|
resolve_default_profile,
|
||||||
validate_git_url,
|
validate_git_url,
|
||||||
@@ -43,6 +45,34 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _running_profile_outcomes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Report running instances that must restart to adopt a profile revision."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(ToolInstance.status == "running")
|
||||||
|
)
|
||||||
|
outcomes: list[dict[str, str]] = []
|
||||||
|
for instance in result.scalars().all():
|
||||||
|
if instance.selected_config_profile_id is None:
|
||||||
|
continue
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
dependencies = {resolved.profile_id} | {
|
||||||
|
uuid.UUID(item["id"])
|
||||||
|
for item in resolved.included_profiles
|
||||||
|
if item.get("id")
|
||||||
|
}
|
||||||
|
if profile_id in dependencies:
|
||||||
|
outcomes.append(
|
||||||
|
{
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": "restart_required",
|
||||||
|
"reason": "Existing instance must restart to adopt shared profile mounts",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
@@ -140,8 +170,47 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
profile = await update_profile(session, profile, data)
|
profile = await update_profile(session, profile, data)
|
||||||
|
response = profile_to_response(profile)
|
||||||
|
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 profile_to_response(profile)
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -161,6 +230,16 @@ async def delete_config_profile(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||||
|
if outcomes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Profile is still used by running instances",
|
||||||
|
"outcomes": outcomes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from asyncio import QueueFull
|
||||||
|
from json import JSONDecodeError
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
@@ -31,6 +33,9 @@ from src.services.terminal.terminal_manager import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_PENDING_INPUT_MESSAGES = 64
|
||||||
|
MAX_TERMINAL_INPUT_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class SessionRef:
|
class SessionRef:
|
||||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
@@ -320,8 +325,38 @@ async def _handle_terminal_websocket(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _input_write_loop(input_queue: asyncio.Queue[tuple[object, bytes]]) -> None:
|
||||||
|
"""Serialize PTY writes without blocking terminal control messages."""
|
||||||
|
while True:
|
||||||
|
session, data = await input_queue.get()
|
||||||
|
try:
|
||||||
|
await session.write_input(data) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Terminal input write failed", exc_info=True)
|
||||||
|
finally:
|
||||||
|
input_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_terminal_input(
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]], session: object, data: bytes
|
||||||
|
) -> bool:
|
||||||
|
"""Queue bounded terminal input without blocking control-message processing."""
|
||||||
|
if len(data) > MAX_TERMINAL_INPUT_BYTES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
input_queue.put_nowait((session, data))
|
||||||
|
except QueueFull:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Receive terminal messages while a dedicated worker serializes PTY input."""
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(
|
||||||
|
maxsize=MAX_PENDING_INPUT_MESSAGES
|
||||||
|
)
|
||||||
|
input_writer = asyncio.create_task(_input_write_loop(input_queue))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
session = session_ref.session
|
session = session_ref.session
|
||||||
@@ -331,7 +366,12 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
message = await websocket.receive()
|
message = await websocket.receive()
|
||||||
if message["type"] == "websocket.receive":
|
if message["type"] == "websocket.receive":
|
||||||
if "bytes" in message:
|
if "bytes" in message:
|
||||||
await session.write_input(message["bytes"])
|
if not _queue_terminal_input(
|
||||||
|
input_queue, session, message["bytes"]
|
||||||
|
):
|
||||||
|
logger.warning("Terminal input buffer exceeded for %s", instance_id)
|
||||||
|
await websocket.close(code=1009, reason="Terminal input buffer full")
|
||||||
|
break
|
||||||
elif "text" in message:
|
elif "text" in message:
|
||||||
text = message["text"]
|
text = message["text"]
|
||||||
# A text frame that parses to a JSON object with a
|
# A text frame that parses to a JSON object with a
|
||||||
@@ -345,13 +385,22 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
if text.startswith("{"):
|
if text.startswith("{"):
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(text)
|
parsed = json.loads(text)
|
||||||
except json.JSONDecodeError:
|
except JSONDecodeError:
|
||||||
parsed = None
|
parsed = None
|
||||||
if isinstance(parsed, dict) and "type" in parsed:
|
if isinstance(parsed, dict) and "type" in parsed:
|
||||||
ctrl = parsed
|
ctrl = parsed
|
||||||
|
|
||||||
if ctrl is None:
|
if ctrl is None:
|
||||||
await session.write_input(text.encode("utf-8"))
|
if not _queue_terminal_input(
|
||||||
|
input_queue, session, text.encode("utf-8")
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Terminal input buffer exceeded for %s", instance_id
|
||||||
|
)
|
||||||
|
await websocket.close(
|
||||||
|
code=1009, reason="Terminal input buffer full"
|
||||||
|
)
|
||||||
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg_type = ctrl["type"]
|
msg_type = ctrl["type"]
|
||||||
@@ -403,8 +452,14 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
continue
|
continue
|
||||||
elif message["type"] == "websocket.disconnect":
|
elif message["type"] == "websocket.disconnect":
|
||||||
break
|
break
|
||||||
except Exception:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
except (RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
logger.debug("Terminal WebSocket receive loop ended: %s", exc)
|
||||||
|
finally:
|
||||||
|
input_writer.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await input_writer
|
||||||
|
|
||||||
|
|
||||||
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileRefreshOutcome(BaseModel):
|
||||||
|
instance_id: str
|
||||||
|
status: str
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
class ConfigProfileResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
|
|||||||
includes: list[dict]
|
includes: list[dict]
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
class DefaultProfilesUpdate(BaseModel):
|
||||||
|
|||||||
@@ -5,47 +5,17 @@ import logging
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models import ToolType
|
from src.models import ToolDefinitionManifest, ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# All supported built-in tools run their long-lived process with these IDs.
|
||||||
|
# Canonical writable Config Profile mounts can therefore be shared without
|
||||||
|
# per-instance ownership changes.
|
||||||
|
BUILTIN_USER_UID = 1000
|
||||||
|
BUILTIN_USER_GID = 1000
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
BUILTIN_TOOL_TYPES = [
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
"""Create or update built-in tool types in the database.
|
|
||||||
|
|
||||||
Built-in tool types have no creator (created_by_id=None) and provide
|
|
||||||
out-of-the-box tools for users without requiring manual tool creation.
|
|
||||||
"""
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
{
|
||||||
"name": "code-server",
|
"name": "code-server",
|
||||||
"display_name": "VS Code Server",
|
"display_name": "VS Code Server",
|
||||||
@@ -83,6 +53,8 @@ services:
|
|||||||
container_name: {{TOOL_NAME}}
|
container_name: {{TOOL_NAME}}
|
||||||
environment:
|
environment:
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
- JUPYTER_ENABLE_LAB=yes
|
||||||
|
- NB_UID=1000
|
||||||
|
- NB_GID=1000
|
||||||
volumes:
|
volumes:
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
- {{REPO_PATH}}:/home/jovyan/work
|
||||||
ports:
|
ports:
|
||||||
@@ -102,31 +74,95 @@ services:
|
|||||||
opencode:
|
opencode:
|
||||||
image: node:20-slim
|
image: node:20-slim
|
||||||
container_name: {{TOOL_NAME}}
|
container_name: {{TOOL_NAME}}
|
||||||
working_dir: /home/user/{{WORKSPACE_NAME}}
|
working_dir: /home/node/{{WORKSPACE_NAME}}
|
||||||
volumes:
|
volumes:
|
||||||
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}}
|
- {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
command: >
|
command: >
|
||||||
sh -c "set -x &&
|
sh -ec "apt-get update && apt-get install -y git ca-certificates &&
|
||||||
apt-get update && apt-get install -y git ca-certificates &&
|
npm install -g opencode-ai &&
|
||||||
echo 'Installing opencode...' &&
|
exec setpriv --reuid=node --regid=node --init-groups opencode server"
|
||||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
||||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
||||||
npm bin -g &&
|
|
||||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
||||||
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
|
||||||
echo 'cd /home/user/{{WORKSPACE_NAME}}' >> /root/.bashrc &&
|
|
||||||
echo 'OpenCode installation complete' &&
|
|
||||||
exec tail -f /dev/null"
|
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
restart: 'no'""",
|
restart: 'no'""",
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
|
||||||
|
def _standardize_builtin_manifest_user(manifest: dict) -> bool:
|
||||||
|
"""Set the built-in manifest user to the shared UID/GID in place.
|
||||||
|
|
||||||
|
The helper deliberately recognizes only Headquarter's conventional
|
||||||
|
``user`` account so it cannot rewrite a future custom tool definition.
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if not isinstance(user, dict) or user.get("name") != "user":
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = user.get("uid") != BUILTIN_USER_UID or user.get("gid") != BUILTIN_USER_GID
|
||||||
|
if changed:
|
||||||
|
user["uid"] = BUILTIN_USER_UID
|
||||||
|
user["gid"] = BUILTIN_USER_GID
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _standardize_pi_agent_manifest(session) -> None:
|
||||||
|
"""Bring the built-in Pi Agent manifest in line with shared mount IDs."""
|
||||||
|
manifest_definition = await session.scalar(
|
||||||
|
select(ToolDefinitionManifest).where(
|
||||||
|
ToolDefinitionManifest.name == "pi-agent",
|
||||||
|
ToolDefinitionManifest.created_by_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if manifest_definition is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest = dict(manifest_definition.manifest)
|
||||||
|
if _standardize_builtin_manifest_user(manifest):
|
||||||
|
manifest_definition.manifest = manifest
|
||||||
|
logger.info("Standardized built-in Pi Agent user to 1000:1000")
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_exists(session, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists in the database."""
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = :table_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() or False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_builtin_tool_types():
|
||||||
|
"""Create or update built-in tool types in the database.
|
||||||
|
|
||||||
|
Built-in tool types have no creator (created_by_id=None) and provide
|
||||||
|
out-of-the-box tools for users without requiring manual tool creation.
|
||||||
|
"""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
# Check if tool_types table exists before attempting to seed
|
||||||
|
if not await _table_exists(session, "tool_types"):
|
||||||
|
logger.warning(
|
||||||
|
"tool_types table does not exist. Skipping seeding. "
|
||||||
|
"Migrations may not have run yet."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _standardize_pi_agent_manifest(session)
|
||||||
|
|
||||||
|
for tool_data in BUILTIN_TOOL_TYPES:
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ToolType).where(ToolType.name == tool_data["name"])
|
select(ToolType).where(ToolType.name == tool_data["name"])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ and cycle protection.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -481,6 +482,7 @@ def apply_resolved_profile(
|
|||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
home_dir: str = "/root",
|
home_dir: str = "/root",
|
||||||
|
working_dir: str | None = None,
|
||||||
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
"""Apply a resolved profile to an instance directory.
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
@@ -489,6 +491,8 @@ def apply_resolved_profile(
|
|||||||
Args:
|
Args:
|
||||||
instance_dir: Path to the instance directory.
|
instance_dir: Path to the instance directory.
|
||||||
resolved: The resolved profile.
|
resolved: The resolved profile.
|
||||||
|
home_dir: Container home directory used for path expansion.
|
||||||
|
working_dir: Container working directory for top-level profile files.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
@@ -501,49 +505,57 @@ def apply_resolved_profile(
|
|||||||
|
|
||||||
instance_path = Path(instance_dir)
|
instance_path = Path(instance_dir)
|
||||||
env_vars = dict(resolved.env_vars)
|
env_vars = dict(resolved.env_vars)
|
||||||
files = dict(resolved.files)
|
working_dir = working_dir or home_dir
|
||||||
volume_mounts = []
|
volume_mounts = []
|
||||||
|
|
||||||
# Write profile files to instance directory
|
# Profile content belongs to the profile, not an individual tool instance.
|
||||||
for file_path, content in files.items():
|
# Keeping it beside the instance root gives every compatible instance the
|
||||||
full_path = instance_path / file_path
|
# same host source while retaining the existing instance storage setting.
|
||||||
try:
|
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
files_dir = profile_dir / "files"
|
||||||
except ValueError:
|
mounts_dir = profile_dir / "mounts"
|
||||||
logger.warning(
|
|
||||||
"Profile file path escapes instance directory: %s", file_path
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Stage mount directories and prepare directory-level volume mounts.
|
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
|
||||||
# Each ResolvedMount targets a container directory; we stage all of its
|
path = root / relative_path
|
||||||
# files under a single host directory and bind-mount that directory. This
|
try:
|
||||||
# keeps the target directory writable by the container user, instead of
|
path.resolve().relative_to(root.resolve())
|
||||||
# having Docker create a root-owned parent directory when only individual
|
except ValueError:
|
||||||
# files are mounted.
|
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
||||||
|
return None
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
||||||
|
) as temporary_file:
|
||||||
|
temporary_file.write(content)
|
||||||
|
temporary_path = Path(temporary_file.name)
|
||||||
|
temporary_path.replace(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Top-level profile files are individual bind mounts under the working
|
||||||
|
# directory. They therefore cannot mask the workspace directory itself.
|
||||||
|
for file_path, content in resolved.files.items():
|
||||||
|
canonical_file = write_canonical_file(files_dir, file_path, content)
|
||||||
|
if canonical_file is None:
|
||||||
|
continue
|
||||||
|
volume_mounts.append(
|
||||||
|
{
|
||||||
|
"source": str(canonical_file),
|
||||||
|
"target": os.path.normpath(os.path.join(working_dir, file_path)),
|
||||||
|
"type": "bind",
|
||||||
|
"readonly": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Explicit profile mounts remain directory-level bind mounts, but use the
|
||||||
|
# same profile-scoped canonical source for every instance.
|
||||||
for mount in resolved.mounts.values():
|
for mount in resolved.mounts.values():
|
||||||
if not mount.files:
|
if not mount.files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expanded_target = os.path.normpath(
|
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
|
||||||
expand_container_path(mount.target, home_dir)
|
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
|
||||||
)
|
|
||||||
mount_dir = (
|
|
||||||
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
|
|
||||||
)
|
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in mount.files.items():
|
for file_path, content in mount.files.items():
|
||||||
full_path = mount_dir / file_path
|
write_canonical_file(mount_dir, file_path, content)
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(mount_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
volume_mounts.append(
|
volume_mounts.append(
|
||||||
{
|
{
|
||||||
@@ -554,7 +566,9 @@ def apply_resolved_profile(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
# Files are now mounted directly from canonical storage, not copied into
|
||||||
|
# the instance directory for write_config_files().
|
||||||
|
return env_vars, {}, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
def expand_container_path(path: str, home_dir: str) -> str:
|
def expand_container_path(path: str, home_dir: str) -> str:
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -283,16 +288,27 @@ def clone_git_repo(
|
|||||||
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
|
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
|
||||||
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
||||||
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
||||||
repo_path = clone_repository(
|
clone_name = _slugify_directory_name(project_name) if project_name else "repo-clone"
|
||||||
remote_url,
|
repo_path = os.path.join(clone_dir, clone_name)
|
||||||
None, # No SSH key for now - can be added later
|
|
||||||
clone_dir,
|
|
||||||
branch or "main",
|
|
||||||
project_name=project_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.exists(repo_path):
|
if os.path.isdir(os.path.join(repo_path, ".git")):
|
||||||
|
# Reuse the deterministic per-repository cache on repeated starts.
|
||||||
try:
|
try:
|
||||||
|
pull_repository_updates(repo_path, remote_url)
|
||||||
|
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# A failed clone can leave its destination behind. Remove only the
|
||||||
|
# computed clone path so the next start can retry cleanly.
|
||||||
|
if os.path.lexists(repo_path):
|
||||||
|
logger.warning("Removing incomplete git mount clone at %s", repo_path)
|
||||||
|
if os.path.isdir(repo_path) and not os.path.islink(repo_path):
|
||||||
|
shutil.rmtree(repo_path)
|
||||||
|
else:
|
||||||
|
os.unlink(repo_path)
|
||||||
|
|
||||||
os.makedirs(clone_dir, exist_ok=True)
|
os.makedirs(clone_dir, exist_ok=True)
|
||||||
repo_path = clone_repository(
|
repo_path = clone_repository(
|
||||||
remote_url,
|
remote_url,
|
||||||
@@ -305,13 +321,6 @@ def clone_git_repo(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
||||||
raise
|
raise
|
||||||
else:
|
|
||||||
# Repo exists - pull latest updates
|
|
||||||
try:
|
|
||||||
pull_repository_updates(repo_path, remote_url)
|
|
||||||
logger.debug("Pulled updates for git mount %s", remote_url)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
|
||||||
|
|
||||||
# Handle branch checkout if specified
|
# Handle branch checkout if specified
|
||||||
if branch and repo_path:
|
if branch and repo_path:
|
||||||
@@ -421,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]:
|
||||||
@@ -443,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(
|
||||||
@@ -464,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:
|
||||||
@@ -1398,17 +1412,22 @@ async def start_tool_instance(
|
|||||||
resolved = await resolve_profile(
|
resolved = await resolve_profile(
|
||||||
session, instance.selected_config_profile_id
|
session, instance.selected_config_profile_id
|
||||||
)
|
)
|
||||||
|
# Profile working-directory hints determine where individual
|
||||||
|
# canonical profile files are bind-mounted at container creation.
|
||||||
|
profile_hints = resolved.runtime_hints
|
||||||
|
if profile_hints.get("working_directory"):
|
||||||
|
working_directory = expand_container_path(
|
||||||
|
profile_hints["working_directory"], home_dir
|
||||||
|
)
|
||||||
profile_env, profile_files, profile_mounts, profile_hints = (
|
profile_env, profile_files, profile_mounts, profile_hints = (
|
||||||
apply_resolved_profile(instance_dir, resolved, home_dir)
|
apply_resolved_profile(
|
||||||
|
instance_dir, resolved, home_dir, working_directory
|
||||||
|
)
|
||||||
)
|
)
|
||||||
# Profile hints override the manifest/tool defaults, and git mounts
|
# Profile hints override the manifest/tool defaults, and git mounts
|
||||||
# need the final working directory to resolve relative target paths.
|
# need the final working directory to resolve relative target paths.
|
||||||
if profile_hints.get("start_command"):
|
if profile_hints.get("start_command"):
|
||||||
start_command = profile_hints["start_command"]
|
start_command = profile_hints["start_command"]
|
||||||
if profile_hints.get("working_directory"):
|
|
||||||
working_directory = expand_container_path(
|
|
||||||
profile_hints["working_directory"], home_dir
|
|
||||||
)
|
|
||||||
if profile_hints.get("port_override"):
|
if profile_hints.get("port_override"):
|
||||||
port_override = profile_hints["port_override"]
|
port_override = profile_hints["port_override"]
|
||||||
env_vars.update(profile_env)
|
env_vars.update(profile_env)
|
||||||
@@ -1472,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:
|
||||||
@@ -2185,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,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from src.api.system.terminal import (
|
||||||
|
MAX_TERMINAL_INPUT_BYTES,
|
||||||
|
SessionRef,
|
||||||
|
_queue_terminal_input,
|
||||||
|
_write_loop,
|
||||||
|
)
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
@@ -19,12 +27,79 @@ class TestTerminalWebSocketMultiSession:
|
|||||||
# the route exists by checking for a 403 (no auth cookie)
|
# the route exists by checking for a 403 (no auth cookie)
|
||||||
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
||||||
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
||||||
assert response.status_code in (403, 404)
|
assert response.status_code == 403 or response.status_code == 404
|
||||||
|
|
||||||
def test_default_session_alias_route_exists(self, client):
|
def test_default_session_alias_route_exists(self, client):
|
||||||
"""The default session alias route should still exist."""
|
"""The default session alias route should still exist."""
|
||||||
response = client.get("/ws/tool-instances/test-instance/terminal")
|
response = client.get("/ws/tool-instances/test-instance/terminal")
|
||||||
assert response.status_code in (403, 404)
|
assert response.status_code == 403 or response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_input_queue_rejects_excess_input_without_blocking() -> None:
|
||||||
|
"""A stalled PTY writer cannot make the input queue grow without limit."""
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(maxsize=1)
|
||||||
|
session = object()
|
||||||
|
|
||||||
|
assert _queue_terminal_input(input_queue, session, b"first")
|
||||||
|
assert not _queue_terminal_input(input_queue, session, b"second")
|
||||||
|
assert not _queue_terminal_input(
|
||||||
|
asyncio.Queue(), session, b"x" * (MAX_TERMINAL_INPUT_BYTES + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ack_is_processed_while_a_pty_write_is_waiting() -> None:
|
||||||
|
"""A blocked paste writer must not block flow-control acknowledgements."""
|
||||||
|
|
||||||
|
write_started = asyncio.Event()
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
_closed = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.acks: list[int] = []
|
||||||
|
self.write_finished = False
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def write_input(self, _data: bytes) -> None:
|
||||||
|
write_started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
self.write_finished = True
|
||||||
|
|
||||||
|
def acknowledge_data(self, char_count: int) -> None:
|
||||||
|
self.acks.append(char_count)
|
||||||
|
|
||||||
|
class WebSocket:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages = iter(
|
||||||
|
[
|
||||||
|
{"type": "websocket.receive", "bytes": b"large paste"},
|
||||||
|
{"type": "websocket.receive", "text": '{"type":"ack","chars":4096}'},
|
||||||
|
{"type": "websocket.disconnect"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.receive_count = 0
|
||||||
|
|
||||||
|
async def receive(self):
|
||||||
|
self.receive_count += 1
|
||||||
|
if self.receive_count > 1:
|
||||||
|
await write_started.wait()
|
||||||
|
return next(self.messages)
|
||||||
|
|
||||||
|
session = Session()
|
||||||
|
websocket = WebSocket()
|
||||||
|
task = asyncio.create_task(_write_loop(SessionRef(session), websocket, "instance"))
|
||||||
|
|
||||||
|
await asyncio.wait_for(write_started.wait(), timeout=0.1)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert session.acks == [4096]
|
||||||
|
|
||||||
|
await task
|
||||||
|
assert session.write_finished
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalRestApi:
|
class TestTerminalRestApi:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for the shared non-root user used by built-in tools."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.seeds.builtin_tool_types import (
|
||||||
|
BUILTIN_TOOL_TYPES,
|
||||||
|
BUILTIN_USER_GID,
|
||||||
|
BUILTIN_USER_UID,
|
||||||
|
_standardize_builtin_manifest_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
|
||||||
|
"""Every legacy built-in Compose tool declares the shared UID/GID."""
|
||||||
|
templates = {
|
||||||
|
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "- PUID=1000" in templates["code-server"]
|
||||||
|
assert "- PGID=1000" in templates["code-server"]
|
||||||
|
assert "- NB_UID=1000" in templates["jupyter-notebook"]
|
||||||
|
assert "- NB_GID=1000" in templates["jupyter-notebook"]
|
||||||
|
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_builtin_user_manifest_is_standardized() -> None:
|
||||||
|
"""The startup migration cannot rewrite a future custom tool user."""
|
||||||
|
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
|
||||||
|
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
|
||||||
|
|
||||||
|
assert _standardize_builtin_manifest_user(builtin_manifest)
|
||||||
|
assert builtin_manifest["user"] == {
|
||||||
|
"name": "user",
|
||||||
|
"uid": BUILTIN_USER_UID,
|
||||||
|
"gid": BUILTIN_USER_GID,
|
||||||
|
}
|
||||||
|
assert not _standardize_builtin_manifest_user(custom_manifest)
|
||||||
|
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_image_templates_define_shared_ids() -> None:
|
||||||
|
"""Project-owned image templates explicitly create or map UID/GID 1000."""
|
||||||
|
root = Path(__file__).resolve().parents[4]
|
||||||
|
sources = {
|
||||||
|
name: (root / "tool-images" / name).read_text()
|
||||||
|
for name in (
|
||||||
|
"base.dockerfile",
|
||||||
|
"opencode.dockerfile",
|
||||||
|
"pi-agent.dockerfile",
|
||||||
|
"code-server.dockerfile",
|
||||||
|
"jupyter.dockerfile",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
|
||||||
|
assert "groupadd -g 1000 user" in sources[name]
|
||||||
|
assert "useradd -m -u 1000 -g 1000" in sources[name]
|
||||||
|
|
||||||
|
assert "PUID=1000" in sources["code-server.dockerfile"]
|
||||||
|
assert "PGID=1000" in sources["code-server.dockerfile"]
|
||||||
|
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
|
||||||
|
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
|
||||||
@@ -532,6 +532,54 @@ class TestApplyResolvedProfile:
|
|||||||
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
||||||
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
||||||
|
|
||||||
|
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
|
||||||
|
"""Top-level files are shared safely without mounting over a workspace."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
files={".tool/config.toml": "setting = true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
_, files, volumes, _ = apply_resolved_profile(
|
||||||
|
str(instance_root / "instance-a"),
|
||||||
|
resolved,
|
||||||
|
working_dir="/workspace/project",
|
||||||
|
)
|
||||||
|
|
||||||
|
canonical_file = (
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
|
||||||
|
)
|
||||||
|
assert files == {}
|
||||||
|
assert volumes == [
|
||||||
|
{
|
||||||
|
"source": str(canonical_file),
|
||||||
|
"target": "/workspace/project/.tool/config.toml",
|
||||||
|
"type": "bind",
|
||||||
|
"readonly": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert canonical_file.read_text() == "setting = true"
|
||||||
|
|
||||||
|
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||||
|
"""Different instance paths resolve a profile to one canonical source."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
|
||||||
|
|
||||||
|
assert first_volumes[0]["source"] == second_volumes[0]["source"]
|
||||||
|
assert first_volumes[0]["source"] == str(
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||||
"""A mount with no files should not produce any volume entries."""
|
"""A mount with no files should not produce any volume entries."""
|
||||||
resolved = ResolvedProfile(
|
resolved = ResolvedProfile(
|
||||||
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
|
|||||||
|
|
||||||
assert len(volumes) == 1
|
assert len(volumes) == 1
|
||||||
assert volumes[0]["target"] == "/etc/app"
|
assert volumes[0]["target"] == "/etc/app"
|
||||||
assert volumes[0].get("readonly") is True
|
assert volumes[0].get("readonly")
|
||||||
|
|
||||||
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
||||||
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
||||||
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
|
|||||||
|
|
||||||
assert len(volumes) == 1
|
assert len(volumes) == 1
|
||||||
assert volumes[0]["target"] == "/app"
|
assert volumes[0]["target"] == "/app"
|
||||||
assert volumes[0].get("readonly") is False
|
assert not volumes[0].get("readonly")
|
||||||
|
|
||||||
|
|
||||||
class TestCheckIncludeCycle:
|
class TestCheckIncludeCycle:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for the tool instance service."""
|
"""Unit tests for the tool instance service."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import MagicMock, AsyncMock
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import pytest
|
|||||||
from src.services.tool.instance_service import (
|
from src.services.tool.instance_service import (
|
||||||
_get_repository_mount_name,
|
_get_repository_mount_name,
|
||||||
_stack_profile_mounts_with_git_mounts,
|
_stack_profile_mounts_with_git_mounts,
|
||||||
|
clone_git_repo,
|
||||||
modify_compose_file,
|
modify_compose_file,
|
||||||
prepare_manifest_instance,
|
prepare_manifest_instance,
|
||||||
)
|
)
|
||||||
@@ -67,14 +69,66 @@ class TestGetRepositoryMountName:
|
|||||||
assert _get_repository_mount_name(project, repo) == "project-v2-0"
|
assert _get_repository_mount_name(project, repo) == "project-v2-0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCloneGitRepo:
|
||||||
|
"""Regression tests for reusable config-profile git mount clones."""
|
||||||
|
|
||||||
|
def test_reuses_existing_clone(self, monkeypatch, tmp_path) -> None:
|
||||||
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
|
remote_url = "https://gitlab.com/example/dotfiles"
|
||||||
|
branch = None
|
||||||
|
clone_parent = str(tmp_path)
|
||||||
|
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
|
||||||
|
repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
|
||||||
|
(repo_path / ".git").mkdir(parents=True)
|
||||||
|
|
||||||
|
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
|
||||||
|
pull = MagicMock()
|
||||||
|
monkeypatch.setattr(instance_service, "clone_repository", clone)
|
||||||
|
monkeypatch.setattr(instance_service, "pull_repository_updates", pull)
|
||||||
|
|
||||||
|
result = clone_git_repo(remote_url, branch, clone_parent)
|
||||||
|
|
||||||
|
assert result == str(repo_path)
|
||||||
|
clone.assert_not_called()
|
||||||
|
pull.assert_called_once_with(str(repo_path), remote_url)
|
||||||
|
|
||||||
|
def test_replaces_incomplete_clone_before_retry(
|
||||||
|
self, monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
|
remote_url = "https://gitlab.com/example/dotfiles"
|
||||||
|
url_hash = hashlib.md5(f"{remote_url}:main".encode()).hexdigest()[:12]
|
||||||
|
clone_dir = tmp_path / "git-mounts" / f"dotfiles-{url_hash}"
|
||||||
|
repo_path = clone_dir / "repo-clone"
|
||||||
|
repo_path.mkdir(parents=True)
|
||||||
|
(repo_path / "partial-file").write_text("incomplete")
|
||||||
|
|
||||||
|
def clone(_url, _key, destination, _branch, project_name=None):
|
||||||
|
assert destination == str(clone_dir)
|
||||||
|
assert project_name is None
|
||||||
|
assert not repo_path.exists()
|
||||||
|
(repo_path / ".git").mkdir(parents=True)
|
||||||
|
return str(repo_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(instance_service, "clone_repository", clone)
|
||||||
|
|
||||||
|
result = clone_git_repo(remote_url, "main", str(tmp_path))
|
||||||
|
|
||||||
|
assert result == str(repo_path)
|
||||||
|
assert (repo_path / ".git").is_dir()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
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")
|
||||||
@@ -99,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")
|
||||||
@@ -129,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:
|
||||||
@@ -190,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()
|
||||||
|
|
||||||
@@ -214,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
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#275d4b" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>Headquarter</title>
|
<title>Headquarter</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<title>Headquarter</title>
|
||||||
|
<rect width="64" height="64" rx="15" fill="#275d4b"/>
|
||||||
|
<path fill="#fffef9" d="M17 15h8v13h14V15h8v34h-8V36H25v13h-8z"/>
|
||||||
|
<path fill="#9dcdb7" d="M25 28h14v8H25z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
@@ -1,5 +1,11 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigProfileRefreshOutcome {
|
||||||
|
instance_id: string;
|
||||||
|
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigProfile {
|
export interface ConfigProfile {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -16,6 +22,7 @@ export interface ConfigProfile {
|
|||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
refresh_outcomes?: ConfigProfileRefreshOutcome[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigProfileMount {
|
export interface ConfigProfileMount {
|
||||||
@@ -138,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"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTerminalScrollbackLimit,
|
||||||
|
isCurrentWebSocket,
|
||||||
|
shouldRetryWebSocketClose,
|
||||||
|
} from "./terminal.tsx";
|
||||||
|
|
||||||
|
describe("getTerminalScrollbackLimit", () => {
|
||||||
|
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
|
||||||
|
expect(getTerminalScrollbackLimit(true)).toBe(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => {
|
||||||
|
expect(getTerminalScrollbackLimit(false)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale WebSocket callbacks after a replacement connection", () => {
|
||||||
|
const current = {} as WebSocket;
|
||||||
|
const stale = {} as WebSocket;
|
||||||
|
|
||||||
|
expect(isCurrentWebSocket(current, current)).toBe(true);
|
||||||
|
expect(isCurrentWebSocket(current, stale)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a heartbeat timeout but not a server socket replacement", () => {
|
||||||
|
expect(shouldRetryWebSocketClose(4000, "Heartbeat timeout")).toBe(true);
|
||||||
|
expect(shouldRetryWebSocketClose(4000, "New connection established")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,21 @@ const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x
|
|||||||
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
||||||
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
||||||
|
|
||||||
|
export function getTerminalScrollbackLimit(isMobile: boolean): number {
|
||||||
|
return isMobile ? 10_000 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCurrentWebSocket(
|
||||||
|
current: WebSocket | null,
|
||||||
|
candidate: WebSocket,
|
||||||
|
): boolean {
|
||||||
|
return current === candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRetryWebSocketClose(code: number, reason: string): boolean {
|
||||||
|
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
||||||
|
}
|
||||||
|
|
||||||
function matchesByteSequence(
|
function matchesByteSequence(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
start: number,
|
start: number,
|
||||||
@@ -83,6 +98,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
const bracketedPasteEnabledRef = useRef(false);
|
const bracketedPasteEnabledRef = useRef(false);
|
||||||
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<number | null>(null);
|
||||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||||
onTerminalReadyRef.current = onTerminalReady;
|
onTerminalReadyRef.current = onTerminalReady;
|
||||||
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
||||||
@@ -111,7 +127,23 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
return fontSize;
|
return fontSize;
|
||||||
}, [fontSize]);
|
}, [fontSize]);
|
||||||
|
|
||||||
|
const clearReconnectTimer = useCallback(() => {
|
||||||
|
if (reconnectTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
|
const currentWs = wsRef.current;
|
||||||
|
if (
|
||||||
|
currentWs?.readyState === WebSocket.CONNECTING ||
|
||||||
|
currentWs?.readyState === WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return currentWs;
|
||||||
|
}
|
||||||
|
clearReconnectTimer();
|
||||||
|
|
||||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
@@ -161,6 +193,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
if (!isCurrentWebSocket(wsRef.current, ws)) {
|
||||||
|
ws.close(1000, "Superseded connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
setError(null);
|
setError(null);
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -201,7 +238,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (!termRef.current) return;
|
if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return;
|
||||||
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const data = new Uint8Array(event.data);
|
const data = new Uint8Array(event.data);
|
||||||
@@ -260,6 +297,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
|
if (!isCurrentWebSocket(wsRef.current, ws)) return;
|
||||||
|
wsRef.current = null;
|
||||||
|
if (ackTimeout) window.clearTimeout(ackTimeout);
|
||||||
|
|
||||||
// Clean up heartbeat check
|
// Clean up heartbeat check
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
@@ -275,18 +316,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code === 1000) {
|
if (!shouldRetryWebSocketClose(event.code, event.reason)) {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code === 4000) {
|
// Transient errors: attempt reconnection.
|
||||||
// Server closed old connection for concurrent connection - don't reconnect
|
|
||||||
// The new connection is already established
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transient errors: attempt reconnection
|
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
setError(`Connection closed (code: ${event.code})`);
|
||||||
|
|
||||||
@@ -295,11 +330,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
const delay =
|
const delay =
|
||||||
RECONNECT_DELAY_BASE *
|
RECONNECT_DELAY_BASE *
|
||||||
Math.pow(2, reconnectAttemptsRef.current - 1);
|
Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
setTimeout(() => {
|
clearReconnectTimer();
|
||||||
if (isUnmountingRef.current) {
|
reconnectTimerRef.current = window.setTimeout(() => {
|
||||||
return;
|
reconnectTimerRef.current = null;
|
||||||
}
|
if (
|
||||||
if (document.visibilityState !== "hidden") {
|
!isUnmountingRef.current &&
|
||||||
|
document.visibilityState !== "hidden" &&
|
||||||
|
wsRef.current === null
|
||||||
|
) {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -307,15 +345,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
|
if (!isCurrentWebSocket(wsRef.current, ws)) return;
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError("WebSocket error");
|
setError("WebSocket error");
|
||||||
};
|
};
|
||||||
|
|
||||||
return ws;
|
return ws;
|
||||||
}, [instanceId, sessionId]);
|
}, [clearReconnectTimer, instanceId, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalRef.current) return;
|
if (!terminalRef.current) return;
|
||||||
|
isUnmountingRef.current = false;
|
||||||
|
permanentErrorRef.current = null;
|
||||||
|
|
||||||
// Initialize terminal
|
// Initialize terminal
|
||||||
const currentFontSize = calculateFontSize();
|
const currentFontSize = calculateFontSize();
|
||||||
@@ -326,14 +367,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
// This terminal only ever hosts full-screen TUI tools (pi-agent,
|
// Desktop tools repaint in place, so retaining their normal buffer
|
||||||
// opencode), which repaint in place in the normal buffer and do not
|
// creates stale frames that native wheel scrolling can revisit. Mobile
|
||||||
// use the alternate screen or mouse tracking. With scrollback, every
|
// instead uses its custom touch handler to scroll normal-buffer output,
|
||||||
// repaint accumulates as history → a viewport scrollbar appears and
|
// which requires retained history.
|
||||||
// the mouse-wheel scrolls through stale frames instead of the app.
|
scrollback: getTerminalScrollbackLimit(isMobile),
|
||||||
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
|
|
||||||
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
|
|
||||||
scrollback: 0,
|
|
||||||
ignoreBracketedPasteMode: false,
|
ignoreBracketedPasteMode: false,
|
||||||
fastScrollSensitivity: 0,
|
fastScrollSensitivity: 0,
|
||||||
scrollSensitivity: 0,
|
scrollSensitivity: 0,
|
||||||
@@ -409,7 +447,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
// Open xterm first (must happen before fit)
|
// Open xterm first (must happen before fit)
|
||||||
term.open(container);
|
term.open(container);
|
||||||
term.focus();
|
term.focus();
|
||||||
const ws = connectWebSocket();
|
connectWebSocket();
|
||||||
|
|
||||||
pasteTextRef.current = (text: string) => {
|
pasteTextRef.current = (text: string) => {
|
||||||
const currentWs = wsRef.current;
|
const currentWs = wsRef.current;
|
||||||
@@ -657,14 +695,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
// Visibility API for reconnection
|
// Visibility API for reconnection
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
|
const currentWs = wsRef.current;
|
||||||
if (
|
if (
|
||||||
document.visibilityState === "visible" &&
|
document.visibilityState === "visible" &&
|
||||||
ws &&
|
!permanentErrorRef.current &&
|
||||||
ws.readyState !== WebSocket.OPEN
|
(currentWs === null || currentWs.readyState === WebSocket.CLOSED)
|
||||||
) {
|
) {
|
||||||
if (permanentErrorRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
@@ -673,6 +709,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isUnmountingRef.current = true;
|
isUnmountingRef.current = true;
|
||||||
|
clearReconnectTimer();
|
||||||
clearTimeout(resizeTimeout);
|
clearTimeout(resizeTimeout);
|
||||||
clearTimeout(windowResizeTimeout);
|
clearTimeout(windowResizeTimeout);
|
||||||
clearTimeout(headerHideTimeout);
|
clearTimeout(headerHideTimeout);
|
||||||
@@ -686,8 +723,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||||
pasteTextRef.current = () => {};
|
pasteTextRef.current = () => {};
|
||||||
bracketedPasteEnabledRef.current = false;
|
bracketedPasteEnabledRef.current = false;
|
||||||
if (ws) {
|
const currentWs = wsRef.current;
|
||||||
ws.close(1000, "Component unmounting");
|
wsRef.current = null;
|
||||||
|
if (currentWs) {
|
||||||
|
currentWs.close(1000, "Component unmounting");
|
||||||
}
|
}
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
@@ -699,7 +738,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
// Ignore disposal errors from partially torn-down terminal
|
// Ignore disposal errors from partially torn-down terminal
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [instanceId, connectWebSocket]);
|
}, [instanceId, connectWebSocket, isMobile]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fit: () => {
|
fit: () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
deleteConfigProfile,
|
deleteConfigProfile,
|
||||||
listConfigProfiles,
|
listConfigProfiles,
|
||||||
previewConfigProfile,
|
previewConfigProfile,
|
||||||
|
refreshConfigProfileGitMounts,
|
||||||
updateConfigProfile,
|
updateConfigProfile,
|
||||||
updateProfileIncludes,
|
updateProfileIncludes,
|
||||||
type ConfigProfile,
|
type ConfigProfile,
|
||||||
@@ -34,17 +35,21 @@ export const useConfigProfiles = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||||
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 [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
const [formData, setFormData] =
|
||||||
|
useState<CreateConfigProfileRequest>(defaultForm);
|
||||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
const selectedProfile =
|
||||||
|
profiles.find((p) => p.id === selectedProfileId) || null;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -89,7 +94,9 @@ export const useConfigProfiles = () => {
|
|||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
setIncludedProfileIds(
|
setIncludedProfileIds(
|
||||||
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
profile.includes.map(
|
||||||
|
(inc: { included_profile_id: string }) => inc.included_profile_id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
@@ -208,20 +215,39 @@ export const useConfigProfiles = () => {
|
|||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const newProfile = await createConfigProfile(formData);
|
const newProfile = await createConfigProfile(formData);
|
||||||
if (includedProfileIds.length > 0) {
|
if (includedProfileIds.length > 0) {
|
||||||
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
await updateProfileIncludes(newProfile.id, {
|
||||||
|
includes: includedProfileIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setSelectedProfileId(newProfile.id);
|
setSelectedProfileId(newProfile.id);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
const refreshed = (await listConfigProfiles()).find(
|
||||||
|
(p) => p.id === newProfile.id,
|
||||||
|
);
|
||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
} else if (selectedProfile) {
|
} else if (selectedProfile) {
|
||||||
await updateConfigProfile(selectedProfile.id, formData);
|
const updatedProfile = await updateConfigProfile(
|
||||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
selectedProfile.id,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
await updateProfileIncludes(selectedProfile.id, {
|
||||||
|
includes: includedProfileIds,
|
||||||
|
});
|
||||||
|
const restartOutcomes = updatedProfile.refresh_outcomes?.filter(
|
||||||
|
(outcome) => outcome.status === "restart_required",
|
||||||
|
);
|
||||||
|
if (restartOutcomes?.length) {
|
||||||
|
setError(
|
||||||
|
`Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
const refreshed = (await listConfigProfiles()).find(
|
||||||
|
(p) => p.id === selectedProfile.id,
|
||||||
|
);
|
||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -233,7 +259,8 @@ export const useConfigProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
if (!window.confirm("Are you sure you want to delete this config profile?"))
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await deleteConfigProfile(id);
|
await deleteConfigProfile(id);
|
||||||
if (selectedProfileId === id) {
|
if (selectedProfileId === id) {
|
||||||
@@ -242,8 +269,27 @@ export const useConfigProfiles = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch (err) {
|
||||||
alert("Failed to delete config profile");
|
setError(extractErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,7 +314,10 @@ export const useConfigProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEnvVar = () => {
|
const addEnvVar = () => {
|
||||||
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
env_vars: { ...prev.env_vars, "": "" },
|
||||||
|
}));
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,7 +372,10 @@ export const useConfigProfiles = () => {
|
|||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
const updateMount = (
|
||||||
|
index: number,
|
||||||
|
updates: Partial<ConfigProfile["mounts"][0]>,
|
||||||
|
) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const mounts = [...(prev.mounts || [])];
|
const mounts = [...(prev.mounts || [])];
|
||||||
mounts[index] = { ...mounts[index], ...updates };
|
mounts[index] = { ...mounts[index], ...updates };
|
||||||
@@ -402,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}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ map: openspec/.pi-map.md
|
|||||||
- openspec/changes/archive
|
- openspec/changes/archive
|
||||||
index: openspec/changes/archive/.pi-map.index.md
|
index: openspec/changes/archive/.pi-map.index.md
|
||||||
map: openspec/changes/archive/.pi-map.md
|
map: openspec/changes/archive/.pi-map.md
|
||||||
|
- openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||||
|
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
|
||||||
- openspec/changes/fix-container-status-false-positive
|
- openspec/changes/fix-container-status-false-positive
|
||||||
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
|
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
|
||||||
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
|
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add a Headquarter Favicon
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a compact, recognizable favicon for Headquarter and register it in the web document head.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Use a geometric cream `H` on the product's evergreen brand field. The mark remains identifiable at small browser-tab sizes, avoids font rendering dependencies, and matches both light and dark application themes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/web/public/favicon.svg`
|
||||||
|
- `apps/web/index.html`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The browser uses a dedicated Headquarter favicon.
|
||||||
|
- [ ] The mark remains legible at small sizes and on light or dark browser chrome.
|
||||||
|
- [ ] The HTML declares the icon type and a matching browser theme color.
|
||||||
|
- [ ] Frontend production build passes.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Add a Headquarter Favicon — Tasks
|
||||||
|
|
||||||
|
- [x] Create the favicon asset.
|
||||||
|
- [x] Register the favicon and browser theme color in the web entry document.
|
||||||
|
- [x] Run the frontend production build.
|
||||||
|
- [x] Update project maps for changed source files.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# openspec/changes/fix-config-profile-git-mount-clone-reuse (index)
|
||||||
|
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
|
||||||
|
## role
|
||||||
|
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
|
||||||
|
## parent
|
||||||
|
index: openspec/changes/.pi-map.index.md
|
||||||
|
map: openspec/changes/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- change.md
|
||||||
|
- tasks.md
|
||||||
|
## links
|
||||||
|
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||||
|
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
|
||||||
|
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
|
||||||
|
## files
|
||||||
|
- change.md | Describes the cached clone regression, required behavior, implementation scope, and verification plan.
|
||||||
|
- tasks.md | Tracks investigation, regression testing, implementation, project-map maintenance, verification, and commit status.
|
||||||
|
## arch
|
||||||
|
Documentation-only OpenSpec change package with separate change rationale and implementation task checklist.
|
||||||
|
## tags
|
||||||
|
config, profile, git, mount, clone, cache, startup, fix
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Fix config profile git mount clone reuse
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Starting a tool instance with a Config Profile git mount can omit the mount when the repository was already cloned into the instance cache. The startup log reports that the destination path already exists and is not an empty directory.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`clone_git_repo()` computes a deterministic cache directory but calls `clone_repository()` before checking whether that directory already contains a clone. `git clone` therefore fails on repeated starts or overlapping start requests. `resolve_single_git_mount()` treats auxiliary mount failures as non-blocking, so startup continues without the configured volume.
|
||||||
|
|
||||||
|
## Required behavior
|
||||||
|
|
||||||
|
1. A valid existing git mount clone must be reused and updated instead of cloned again.
|
||||||
|
2. A missing clone must still be created normally.
|
||||||
|
3. An incomplete clone directory must not permanently prevent a later retry.
|
||||||
|
4. A clone/update failure remains non-blocking at the git mount resolver boundary.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Correct clone-cache handling in `apps/api/src/services/tool/instance_service.py`.
|
||||||
|
- Add focused regression tests in `apps/api/tests/unit/test_instance_service.py`.
|
||||||
|
- No API, database, frontend, or Docker Compose contract changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Targeted `pytest` for git mount clone reuse and instance service tests.
|
||||||
|
- Ruff and mypy checks for changed backend files.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Tasks: fix config profile git mount clone reuse
|
||||||
|
|
||||||
|
- [x] Capture the failing runtime trace from an affected tool session.
|
||||||
|
- [x] Add a regression test proving a valid cached clone is reused.
|
||||||
|
- [x] Update `clone_git_repo()` to check the deterministic clone path before cloning.
|
||||||
|
- [x] Recover safely from an incomplete clone directory.
|
||||||
|
- [x] Run targeted backend tests and quality checks.
|
||||||
|
- [x] Update project maps and validate map freshness.
|
||||||
|
- [x] Commit the verified fix.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Restore Mobile Terminal Scrolling
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The recent terminal scrollback optimization disabled scrollback for every viewport. Mobile terminal swipe handling still scrolls xterm's normal buffer programmatically, so swipes in normal-buffer tools no longer have retained output to move through.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`468f342` changed the terminal configuration to `scrollback: 0` globally to prevent stale repaint frames and wheel scrolling on desktop. The mobile touch handler calls `term.scrollLines()` when the normal buffer is active. With zero scrollback, that call has no scrollable history and becomes a no-op.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/terminal/terminal.tsx`
|
||||||
|
- Focused terminal configuration test
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Retain a bounded xterm scrollback buffer on mobile only (`10000` lines), while leaving desktop at zero scrollback and with wheel sensitivity disabled. Mobile's existing custom touch handler remains responsible for moving through normal-buffer history; alternate-screen swipes continue to send SGR wheel events to the active TUI.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] A mobile terminal with normal-buffer output exceeding one screen scrolls via a vertical swipe.
|
||||||
|
- [ ] Alternate-screen terminal scrolling continues to use the existing SGR wheel-event path.
|
||||||
|
- [ ] Desktop keeps zero xterm scrollback and disabled native wheel scrolling, so stale repaint frames do not return.
|
||||||
|
- [ ] Focused unit test and frontend quality gates pass.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `468f342 fix(terminal): hide scrollbar and stop stale-frame wheel scroll for TUI tools`
|
||||||
|
- `openspec/changes/fix-terminal-container-overflow`
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Restore Mobile Terminal Scrolling — Tasks
|
||||||
|
|
||||||
|
- [x] Add a mobile-specific terminal scrollback limit while preserving zero scrollback on desktop.
|
||||||
|
- [x] Reinitialize the terminal when the responsive mobile classification changes so its scrollback and touch handler match the active viewport.
|
||||||
|
- [x] Add focused tests for the responsive scrollback configuration.
|
||||||
|
- [x] Run the full frontend test suite (89 tests passed after repairing the `ProjectsPage` test setup).
|
||||||
|
- [x] Run frontend typecheck, lint, focused tests, and production build.
|
||||||
|
- [ ] Perform mobile normal-buffer and alternate-screen manual QA.
|
||||||
|
- [ ] Update project maps for changed source files (the map patch tool currently fails with an unsupported `temperature` parameter).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Fix Web Terminal Resilience
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prevent large browser pastes from blocking flow-control acknowledgements, and prevent stale reconnect callbacks from replacing a healthy terminal WebSocket.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The terminal WebSocket handler awaits each PTY write inline. A large paste can wait for the PTY to become writable while the same handler stops receiving acknowledgement messages. If output flow control has paused PTY reads, the acknowledgement that would resume output remains unread, leaving the terminal apparently frozen.
|
||||||
|
|
||||||
|
Separately, reconnect timers and visibility callbacks can create a second socket after a connection becomes healthy. The terminal manager then closes the existing session socket, interrupting active input or rendering.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Queue bounded terminal input onto a single ordered writer so the WebSocket receive loop continues handling acknowledgements, resize, reset, and disconnect messages.
|
||||||
|
- Make browser reconnection single-owner: stale socket callbacks and retry timers MUST NOT replace a current healthy socket.
|
||||||
|
- Add focused regression tests for the queueing and reconnect behavior.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Re-enable desktop normal-buffer scrollback or mouse-wheel scrolling. Desktop continues to use zero scrollback and disabled wheel sensitivity to avoid the known stale TUI-frame regression.
|
||||||
|
- Change terminal session persistence, authentication, PTY transport, or mobile touch scrolling behavior.
|
||||||
|
|
||||||
|
## Risk and rollback
|
||||||
|
|
||||||
|
The bounded input queue must preserve input ordering, reject excess input without blocking control messages, and be cancelled when the WebSocket disconnects. Socket ownership checks must not prevent a legitimate reconnect after a real disconnect. Roll back by reverting the backend queue and frontend ownership changes; existing direct PTY input and retry behavior then resumes.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] A blocked PTY write does not prevent the WebSocket handler from processing a subsequent flow-control acknowledgement.
|
||||||
|
- [ ] Input bytes are still written to the PTY in arrival order, and excess queued input is rejected rather than growing without limit.
|
||||||
|
- [ ] A stale socket close event or retry callback cannot replace an open current socket.
|
||||||
|
- [ ] Component cleanup cancels pending reconnect timers and closes the current socket.
|
||||||
|
- [ ] Desktop scrollback and wheel settings remain unchanged.
|
||||||
|
- [ ] Focused backend/frontend tests and relevant quality gates pass.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Fix Web Terminal Resilience — Tasks
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ------- | ------- |
|
||||||
|
| Estimated changed lines | 180–280 |
|
||||||
|
| 400-line budget risk | Low |
|
||||||
|
| Chained PRs recommended | No |
|
||||||
|
| Suggested split | Single focused change |
|
||||||
|
| Delivery strategy | single-pr |
|
||||||
|
| Chain strategy | feature-branch-chain |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: No
|
||||||
|
Chain strategy: feature-branch-chain
|
||||||
|
400-line budget risk: Low
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] **RED — backend input/control concurrency:** characterize a PTY write that waits for readiness while an acknowledgement is received; prove the acknowledgement is handled without waiting for that write to finish.
|
||||||
|
- [x] **GREEN — ordered input writer:** move PTY writes behind one cancellable ordered queue/worker while retaining the current public WebSocket message protocol and input ordering.
|
||||||
|
- [x] **TRIANGULATE — lifecycle:** cover worker cancellation and queued-write failure/disconnect handling.
|
||||||
|
- [x] **RED — frontend socket ownership:** characterize stale close/retry callbacks after a newer socket has become current.
|
||||||
|
- [x] **GREEN — reconnect ownership:** ensure only the current socket can update state or schedule a retry; cancel retry timers during cleanup.
|
||||||
|
- [x] **REFACTOR:** keep the connection lifecycle readable and avoid changing the intentional desktop scrollback configuration.
|
||||||
|
- [x] **Verify:** run targeted backend and frontend tests, frontend typecheck/lint/build, backend checks practical in the isolated worktree, and inspect diagnostics. (Backend pytest is unavailable locally: no pytest/uv executable; Docker test execution was explicitly declined.)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Refresh profile-managed configuration for running tool instances when a Config Profile is saved, without recreating the container or terminal session.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Resolve the saved profile and refresh every running instance that selected it, including profiles that include it.
|
||||||
|
- Store non-Git profile configuration as a canonical, host-side working copy shared by every instance using the profile.
|
||||||
|
- Bind-mount canonical profile directories directly into their configured container targets and bind-mount canonical profile files individually under the container working directory, preserving the workspace mount.
|
||||||
|
- Allow UI and container-side edits to the same canonical files; last writer wins, with an overwrite warning when detectable.
|
||||||
|
- Defer Git mount refresh and Git-clone mutation to a separate commit-aware feature.
|
||||||
|
- Standardize all supported tool containers on one shared non-root user/group so canonical writable profile mounts remain accessible across instances.
|
||||||
|
- Return per-instance refresh results to the profile-save UI.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Preserve running containers and terminal sessions.
|
||||||
|
- Preserve the workspace/repository mount.
|
||||||
|
- Docker bind-mount topology is immutable at runtime. Added, removed, retargeted, or mode-changed mounts MUST be reported as requiring restart, not partially applied.
|
||||||
|
- Desktop and mobile profile editors use the same save/refresh behavior.
|
||||||
|
- The standardized container user/group MUST be applied to built-in tool definitions, generated manifests, and image templates; legacy/incompatible tool images must report incompatible permissions rather than silently changing profile mount ownership.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Saving a profile refreshes every eligible running instance with a direct or transitive dependency on that profile.
|
||||||
|
- [ ] Canonical non-Git profile files and mount directories are shared writable working copies across compatible running instances.
|
||||||
|
- [ ] Container-side and UI-side changes become visible to all instances using the profile; last writer wins and detectable overwrites generate a warning.
|
||||||
|
- [ ] Git mount refresh and Git clone mutation are not performed by this feature.
|
||||||
|
- [ ] Built-in supported tool containers use a shared non-root user/group compatible with writable canonical profile mounts.
|
||||||
|
- [ ] Topology changes return a restart-required result without recreating the instance.
|
||||||
|
- [ ] Terminal WebSocket sessions remain connected throughout a successful refresh.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Design: Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Canonical working copies
|
||||||
|
|
||||||
|
Each non-Git Config Profile owns canonical host-side storage. Directory mounts use canonical profile directories; each top-level profile file uses a canonical host file bind-mounted under the container working directory. All compatible instances selected for the profile mount the same sources, so UI and container edits are immediately shared.
|
||||||
|
|
||||||
|
## Container compatibility
|
||||||
|
|
||||||
|
Supported built-in tools standardize on one non-root user/group. Existing instances retain their current image/user and report `restart_required`. Custom tools are not rewritten; tools that do not opt into the shared user/group return `incompatible_permissions`.
|
||||||
|
|
||||||
|
## Save behavior
|
||||||
|
|
||||||
|
A profile save writes canonical profile files atomically per file. The save response reports affected compatible instances, `restart_required` topology/runtime changes, `incompatible_permissions`, and detectable overwrite warnings. Last writer wins; no merge or lock protocol is imposed.
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
Git mount mutation is explicitly deferred. Workspace/repository mounts are never changed. Profile deletion is rejected while running instances still use the profile.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementation Plan: Live Config Profile Refresh
|
||||||
|
|
||||||
|
1. Standardize built-in tool user/group definitions and remove ownership-changing behavior for shared profile sources.
|
||||||
|
2. Add canonical profile storage and bind-mount compilation for profile directories and individual working-directory files.
|
||||||
|
3. Add compatibility/topology analysis, running-instance discovery, save-result schema, and deletion guard.
|
||||||
|
4. Wire desktop/mobile profile save results into immediate status/warning feedback.
|
||||||
|
5. Add unit, API, manifest/image, and frontend coverage; run quality gates and manual two-instance QA.
|
||||||
|
|
||||||
|
## Delivery order
|
||||||
|
|
||||||
|
1. Container-user compatibility
|
||||||
|
2. Canonical non-Git profile mounts
|
||||||
|
3. API and deletion semantics
|
||||||
|
4. UI feedback
|
||||||
|
5. Verification and commit
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Git mount refresh and Git clone mutation require a commit-aware follow-up change.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Live Config Profile Refresh — Tasks
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Estimated changed lines | 500–750 |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | Container-user standardization → canonical profile mounts → API/UI feedback → verification |
|
||||||
|
| Delivery strategy | feature-branch-chain |
|
||||||
|
| Chain strategy | feature-branch-chain |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
Chain strategy: feature-branch-chain
|
||||||
|
400-line budget risk: High
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
|
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts.
|
||||||
|
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings.
|
||||||
|
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation.
|
||||||
|
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors.
|
||||||
|
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests.
|
||||||
|
|
||||||
|
## Verification Notes
|
||||||
|
|
||||||
|
- Passed: frontend production build (`npm run build`), Python compilation for changed backend modules, and targeted LSP diagnostics.
|
||||||
|
- Skipped: backend pytest and Ruff; this environment has no project-managed Python runner, system Python lacks those packages, and the user declined system-package installation.
|
||||||
|
- Skipped: Docker/Compose and manual multi-instance checks; explicit Docker approval was not granted.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Test Plan: Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- Canonical file and directory paths are profile-scoped and reject traversal.
|
||||||
|
- Two compatible instances receive the same host mount source.
|
||||||
|
- A container-side file edit is visible through the profile read API and another instance mount.
|
||||||
|
- A profile save updates canonical content and returns overwrite warnings when applicable.
|
||||||
|
- Topology, environment, runtime, and legacy-instance changes return `restart_required`.
|
||||||
|
- Incompatible users return `incompatible_permissions`.
|
||||||
|
- Deleting a profile with running dependents is rejected with their instance identifiers.
|
||||||
|
- Git mount content is unchanged by this feature.
|
||||||
|
|
||||||
|
## Container/image compatibility
|
||||||
|
|
||||||
|
- Each built-in supported image uses the common non-root UID/GID.
|
||||||
|
- Generated manifest Dockerfiles and entrypoints retain that user and writable mount access.
|
||||||
|
- Existing instances are not mutated until restart/recreation.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- Desktop and mobile save flows display refreshed/shared-working-copy, overwrite-warning, restart-required, and incompatible-permissions results.
|
||||||
|
|
||||||
|
## Manual QA
|
||||||
|
|
||||||
|
- Open two compatible instances using one profile; edit a mounted file in one terminal and verify it in the other.
|
||||||
|
- Save a profile edit and verify both running instances see it without terminal disconnection.
|
||||||
|
- Verify profile deletion is blocked while either instance is running.
|
||||||
@@ -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.
|
||||||
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
sudo \
|
sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create a non-root user and allow passwordless sudo so the startup
|
# Use the shared built-in UID/GID so writable Config Profile mounts can be
|
||||||
# permission fixer can adjust ownership of bind-mounted directories.
|
# shared by compatible instances without ownership changes.
|
||||||
RUN useradd -m -s /bin/bash user \
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user \
|
||||||
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
|
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
|
||||||
&& chmod 0440 /etc/sudoers.d/user
|
&& chmod 0440 /etc/sudoers.d/user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Set up git
|
# Set up git
|
||||||
RUN git config --global init.defaultBranch main
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
# Code-server runs as abc user by default
|
# The LinuxServer entrypoint maps abc to this shared UID/GID before starting
|
||||||
|
# code-server, so writable Config Profile mounts are compatible with other
|
||||||
|
# built-in tool containers.
|
||||||
|
ENV PUID=1000 \
|
||||||
|
PGID=1000
|
||||||
USER abc
|
USER abc
|
||||||
|
|
||||||
EXPOSE 8443
|
EXPOSE 8443
|
||||||
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
# Set up git
|
# Set up git
|
||||||
RUN git config --global init.defaultBranch main
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
# Switch back to jovyan user (default for scipy-notebook)
|
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
|
||||||
USER ${NB_UID}
|
# Keep the image root at entrypoint time so that mapping can occur.
|
||||||
|
ENV NB_UID=1000 \
|
||||||
|
NB_GID=1000
|
||||||
|
USER root
|
||||||
|
|
||||||
EXPOSE 8888
|
EXPOSE 8888
|
||||||
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Install OpenCode
|
# Install OpenCode
|
||||||
RUN npm install -g opencode
|
RUN npm install -g opencode
|
||||||
|
|
||||||
# Create non-root user
|
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||||
RUN useradd -m -s /bin/bash user
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|
||||||
# Set up git
|
# Set up git
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Install Pi Coding Agent globally
|
# Install Pi Coding Agent globally
|
||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
# Create non-root user
|
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||||
RUN useradd -m -s /bin/bash user
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|
||||||
# Set up git
|
# Set up git
|
||||||
@@ -37,15 +38,11 @@ RUN git config --global init.defaultBranch main \
|
|||||||
&& git config --global user.email "dev@headquarter.local" \
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
&& git config --global user.name "Developer"
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
# Create default tmux config
|
# Create user-owned default configuration files.
|
||||||
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf \
|
||||||
|
&& mkdir -p /home/user/.config/ranger /home/user/.pi/agent \
|
||||||
# Create default ranger config
|
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
|
||||||
RUN mkdir -p /home/user/.config/ranger \
|
&& chown -R user:user /home/user
|
||||||
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
|
|
||||||
|
|
||||||
# Set up Pi config directory
|
|
||||||
RUN mkdir -p /home/user/.pi/agent
|
|
||||||
|
|
||||||
USER user
|
USER user
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user