Compare commits

...

4 Commits

Author SHA1 Message Date
Developer f2a3399f27 merge: resolve dev branch conflicts, add compose_template guard
Incorporate remote bug fixes into slimmed instance_service.py:
- Add tool_type.compose_template guard before render_compose_template

Quality gates: py_compile pass
2026-06-05 21:32:50 +00:00
Developer 5266e64be2 refactor: slim ConfigProfilesPage to 150 lines
Extract handleReset callback, compact loading/error states.

Quality gates: tsc --noEmit pass
2026-06-05 21:22:44 +00:00
Developer 9a17916dd2 refactor: slim backend routers to ≤500 lines
- tool_instances.py: 2108 → 496 lines
- git_repositories.py: 1422 → 500 lines
- config_profiles.py: 474 → 300 lines (already committed)

Extract business logic into services:
- services/tool/instance_service.py
- services/git/operations.py
- services/config/crud_service.py

Quality gates: py_compile pass on all files
2026-06-05 21:18:57 +00:00
Developer 6efe524974 refactor: slim config_profiles router to 301 lines
Extract CRUD helpers into services/config/crud_service.py.
Move instance-related config logic to services/tool/instance_service.py.

Quality gates: py_compile pass
2026-06-05 20:56:50 +00:00
11 changed files with 1934 additions and 3066 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
}
+7 -9
View File
@@ -1,14 +1,13 @@
# Skill Registry — headquarter
# Skill Registry — workspace
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-06-02
Last updated: 2026-06-05
## Sources scanned
- .opencode/skills
- .claude/skills
- /home/alex/.config/opencode/skills
## Contract
@@ -20,12 +19,11 @@ Last updated: 2026-06-02
| Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
## Loading protocol
+8 -182
View File
@@ -21,19 +21,17 @@ from src.schemas.config import (
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
resolved_profile_to_dict,
)
from src.services.config.crud_service import (
calculate_profile_size,
check_access,
create_profile,
get_or_create_user_config,
get_profile_with_includes,
profile_to_response,
update_includes,
update_profile,
validate_default_profiles,
validate_git_mounts,
MAX_PROFILE_SIZE_BYTES,
)
from src.services.config.resolver_service import (
resolve_default_profile,
@@ -99,63 +97,8 @@ async def create_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
user_uuid = current_user_id
existing = await session.execute(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name,
)
.options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await check_access(session, user_uuid, project_uuid, tool_uuid)
if data.git_mounts:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
size = calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Profile size exceeds 10MB limit",
)
profile = ConfigProfile(
user_id=user_uuid,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
profile = await create_profile(session, current_user_id, data)
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
return profile_to_response(profile)
@@ -196,70 +139,7 @@ async def update_config_profile(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await check_access(session, profile.user_id, project_uuid, tool_uuid)
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
current_data = profile_to_response(profile)
merged = {**current_data, **update_data}
size = calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Profile size exceeds 10MB limit",
)
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
profile = await update_profile(session, profile, data)
logger.debug("Updated config profile %s", profile.id)
return profile_to_response(profile)
@@ -289,7 +169,7 @@ async def delete_config_profile(
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes(
async def update_profile_includes_endpoint(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
@@ -307,61 +187,7 @@ async def update_profile_includes(
)
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
for inc_uuid in included_uuids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
for inc_uuid in included_uuids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
for order_index, inc_uuid in enumerate(included_uuids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
profile = result.scalar_one()
profile = await update_includes(session, profile, included_uuids, current_user_id)
inc_result = await session.execute(
select(ConfigProfileInclude).where(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -45,3 +45,91 @@ class GitRepositoryResponse(BaseModel):
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
class FileListResponse(BaseModel):
path: str
branch: str
entries: list[dict]
class FileContentResponse(BaseModel):
path: str
branch: str
content: str
size: int
encoding: str
language: str | None
is_binary: bool
last_commit: dict | None
class BranchesResponse(BaseModel):
branches: list[dict]
default_branch: str
class FileUpdateRequest(BaseModel):
path: str
branch: str
content: str
commit_message: str
class FileUpdateResponse(BaseModel):
commit_hash: str
message: str
branch: str
class StatusResponse(BaseModel):
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
renamed: list[str]
ahead: int
behind: int
class BranchCreateRequest(BaseModel):
name: str
base_branch: str = "HEAD"
class CheckoutRequest(BaseModel):
branch: str
class CommitRequest(BaseModel):
message: str
files: list[str] | None = None
class CommitResponse(BaseModel):
commit_hash: str
message: str
class FetchResponse(BaseModel):
message: str
class PullResponse(BaseModel):
message: str
class PushResponse(BaseModel):
message: str
class MergeRequest(BaseModel):
source_branch: str
target_branch: str | None = None
message: str | None = None
class MergeResponse(BaseModel):
commit_hash: str
message: str
@@ -155,3 +155,201 @@ async def validate_default_profiles(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
async def create_profile(
session: AsyncSession,
user_id: uuid.UUID,
data: Any,
) -> ConfigProfile:
"""Create a new config profile after validation."""
existing = await session.execute(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
)
.options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await check_access(session, user_id, project_uuid, tool_uuid)
if data.git_mounts:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await validate_git_mounts(session, user_id, git_mounts_data, project_uuid)
size = calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Profile size exceeds 10MB limit",
)
profile = ConfigProfile(
user_id=user_id,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one()
async def update_profile(
session: AsyncSession,
profile: ConfigProfile,
data: Any,
) -> ConfigProfile:
"""Update a config profile after validation."""
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await check_access(session, profile.user_id, project_uuid, tool_uuid)
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
current_data = profile_to_response(profile)
merged = {**current_data, **update_data}
size = calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Profile size exceeds 10MB limit",
)
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one()
async def update_includes(
session: AsyncSession,
profile: ConfigProfile,
included_ids: list[uuid.UUID],
user_id: uuid.UUID,
) -> ConfigProfile:
"""Replace profile includes after cycle check."""
for inc_uuid in included_ids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
from src.services.config.config_profile_resolver import check_include_cycle
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_ids:
for inc_uuid in included_ids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
for order_index, inc_uuid in enumerate(included_ids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
return result.scalar_one()
+44
View File
@@ -167,3 +167,47 @@ def init_working_repository(repo_path: str) -> None:
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to set initial branch: {ref_result.stderr}",
)
def list_remote_branches(remote_url: str, ssh_key: SSHKey | None = None) -> tuple[list[str], str]:
"""List branches from a remote repository via ls-remote.
Returns:
Tuple of (branch_names, default_branch).
"""
ssh_result = prepare_ssh_env(ssh_key)
env, key_path = ssh_result if ssh_result else (None, None)
try:
result = subprocess.run(
["git", "ls-remote", "--heads", remote_url],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
if result.returncode != 0:
logger.warning("ls-remote returned %d: %s", result.returncode, result.stderr)
raise RuntimeError(f"ls-remote failed: {result.stderr}")
branches = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/"):]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
return branches, default_branch
except subprocess.TimeoutExpired:
logger.warning("ls-remote timed out for %s", remote_url)
raise RuntimeError("ls-remote timed out")
except Exception as e:
logger.warning("ls-remote failed for %s: %s", remote_url, str(e))
raise RuntimeError(f"ls-remote failed: {e}")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
File diff suppressed because it is too large Load Diff
+12 -32
View File
@@ -61,21 +61,18 @@ export const ConfigProfilesPage = () => {
populateForm,
} = useConfigProfiles();
if (status === "loading") {
return (
<div className="container">
<LoadingState message="Loading Config Profiles..." />
</div>
);
}
const handleReset = () => {
if (isCreating) {
setFormData({ name: "", description: "", env_vars: {}, runtime_hints: {}, mounts: [], git_mounts: [], files: {}, is_default: false });
setIncludedProfileIds([]);
setSaveStatus("idle");
} else if (selectedProfile) {
populateForm(selectedProfile);
}
};
if (status === "error") {
return (
<div className="container">
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
</div>
);
}
if (status === "loading") return <div className="container"><LoadingState message="Loading Config Profiles..." /></div>;
if (status === "error") return <div className="container"><ErrorState message="Failed to load Config Profiles." onRetry={loadData} /></div>;
if (isMobile) {
return (
@@ -126,24 +123,7 @@ export const ConfigProfilesPage = () => {
getScopeLabel={getScopeLabel}
onFormChange={updateFormField}
onSubmit={handleSubmit}
onReset={() => {
if (isCreating) {
setFormData({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
setIncludedProfileIds([]);
setSaveStatus("idle");
} else if (selectedProfile) {
populateForm(selectedProfile);
}
}}
onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
onAddInclude={addInclude}
onRemoveInclude={removeInclude}
View File