Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Blank c051929f8c fix: use host bind mount for repos so tool instances can access workspace files
Replace named Docker volume (repo_data) with bind mount (/data/repos) in both
development and production compose files. The named volume trapped repo files
inside the API container; tool instances started via Docker socket on the host
could not see them, causing /workspace to mount as an empty directory.

Also fix 6 pre-existing test failures in test_tool_instances_legacy.py caused
by get_container_id/get_container_name moving to docker.py and new helpers
(_ensure_web_bind_address, _ensure_container_name_in_compose) being added.

- docker-compose.yml: repo_data:/data/repos -> /data/repos:/data/repos
- docker-compose.traefik.yml: same change + remove repo_data volume decl
- tests: update patch targets and add missing mock parameters

Quality gates: pytest test_tool_instances_legacy.py (10 passed)
2026-06-02 12:47:14 +02:00
1194 changed files with 20247 additions and 57274 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
"fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
}
+10 -7
View File
@@ -1,13 +1,14 @@
# Skill Registry — workspace
# Skill Registry — headquarter
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-06-05
Last updated: 2026-05-28
## Sources scanned
- .opencode/skills
- .claude/skills
- /home/alex/.config/opencode/skills
## Contract
@@ -19,11 +20,13 @@ Last updated: 2026-06-05
| Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- |
| `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` |
| `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` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/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` |
## Loading protocol
-20
View File
@@ -1,20 +0,0 @@
# .claude (index)
dir: .claude
## role
Configuration directory for Claude AI assistant settings and custom instructions.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
- .claude/skills
index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md
## files
## links
index: .claude/.pi-map.index.md
map: .claude/.pi-map.md
## workflows
-
## dirty
-
-18
View File
@@ -1,18 +0,0 @@
# .claude
dir: .claude
index: .claude/.pi-map.index.md
## role
Configuration directory for Claude AI assistant settings and custom instructions.
## files
## arch
Simple flat-file configuration storage with no executable code, using human-readable text files for AI behavior customization.
## tags
-
## symbols
-
## workflows
-
## dirty
-
-20
View File
@@ -1,20 +0,0 @@
# .claude/skills (index)
dir: .claude/skills
## role
Contains skill definitions and configuration files for the Claude AI assistant to extend its capabilities with specialized behaviors and domain-specific expertise.
## parent
index: .claude/.pi-map.index.md
map: .claude/.pi-map.md
## children
- .claude/skills/sift-backlog
index: .claude/skills/sift-backlog/.pi-map.index.md
map: .claude/skills/sift-backlog/.pi-map.md
## files
## links
index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md
## workflows
-
## dirty
-
-18
View File
@@ -1,18 +0,0 @@
# .claude/skills
dir: .claude/skills
index: .claude/skills/.pi-map.index.md
## role
Contains skill definitions and configuration files for the Claude AI assistant to extend its capabilities with specialized behaviors and domain-specific expertise.
## files
## arch
Declarative skill configuration system using structured metadata files (likely YAML/JSON) that define prompts, tools, and context boundaries for modular AI capability extensions.
## tags
-
## symbols
-
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .claude/skills/sift-backlog (index)
dir: .claude/skills/sift-backlog
## role
Provides a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool.
## parent
index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md
## children
-
## files
- SKILL.md
## links
index: .claude/skills/sift-backlog/.pi-map.index.md
map: .claude/skills/sift-backlog/.pi-map.md
## workflows
-
## dirty
-
-19
View File
@@ -1,19 +0,0 @@
# .claude/skills/sift-backlog
dir: .claude/skills/sift-backlog
index: .claude/skills/sift-backlog/.pi-map.index.md
## role
Provides a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool.
## files
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool. | dep: sf CLI (task, plan, dependency, update subcommands)
## arch
Skill-based workflow automation using markdown-defined CLI tooling and structured task management patterns.
## tags
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
## symbols
-
## workflows
-
## dirty
-
-4
View File
@@ -17,7 +17,6 @@ __pycache__/
*.so
.python-version
.venv/
.venv-test/
venv/
env/
.pytest_cache/
@@ -55,6 +54,3 @@ Thumbs.db
.atl/
.sisyphus/
.pi-lens/
minerv3/
.cache/
openspec-audit-report.md
-23
View File
@@ -1,23 +0,0 @@
# .opencode (index)
dir: .opencode
## role
This appears to be a configuration or metadata directory for the OpenCode AI coding assistant tool.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
- .opencode/commands
index: .opencode/commands/.pi-map.index.md
map: .opencode/commands/.pi-map.md
- .opencode/skills
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## files
## links
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
## workflows
-
## dirty
-
-18
View File
@@ -1,18 +0,0 @@
# .opencode
dir: .opencode
index: .opencode/.pi-map.index.md
## role
This appears to be a configuration or metadata directory for the OpenCode AI coding assistant tool.
## files
## arch
Hidden directory storing tool-specific state; no discernible software architecture as it contains no source files.
## tags
-
## symbols
-
## workflows
-
## dirty
-
-22
View File
@@ -1,22 +0,0 @@
# .opencode/commands (index)
dir: .opencode/commands
## role
Defines AI assistant command workflows for an OpenSpec-based development system, covering task execution, change archival, exploratory analysis, and new change proposal generation.
## parent
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
## children
-
## files
- opsx-apply.md
- opsx-archive.md
- opsx-explore.md
- opsx-propose.md
## links
index: .opencode/commands/.pi-map.index.md
map: .opencode/commands/.pi-map.md
## workflows
-
## dirty
-
-22
View File
@@ -1,22 +0,0 @@
# .opencode/commands
dir: .opencode/commands
index: .opencode/commands/.pi-map.index.md
## role
Defines AI assistant command workflows for an OpenSpec-based development system, covering task execution, change archival, exploratory analysis, and new change proposal generation.
## files
- opsx-apply.md | Implement tasks from an OpenSpec change by selecting a change, reading context files, and executing pending tasks in a loop until completion or blocked. | dep: openspec CLI, AskUserQuestion tool, filesystem access
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development system, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem operations
- opsx-explore.md | Defines the "explore mode" stance and guidelines for an AI assistant to think through ideas, investigate problems, and clarify requirements without implementing code. | dep: OpenSpec system, ASCII diagrams, codebase investigation tools
- opsx-propose.md | Defines an AI assistant workflow for proposing new changes by creating a scaffolded change directory and generating all required artifacts (proposal.md, design.md, tasks.md) in dependency order using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
## arch
Markdown-based command specifications using a structured workflow pattern with sequential steps, file I/O operations, CLI tool integration (openspec), and human-in-the-loop decision points for validation and confirmation.
## tags
opsx, tasks, openspec, change, openspec cli, askuserquestion tool, defines, explore
## symbols
-
## workflows
-
## dirty
-
-29
View File
@@ -1,29 +0,0 @@
# .opencode/skills (index)
dir: .opencode/skills
## role
Contains custom AI agent skills or capabilities that extend the base functionality of the OpenCode framework with specialized tools and behaviors.
## parent
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
## children
- .opencode/skills/openspec-apply-change
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
map: .opencode/skills/openspec-apply-change/.pi-map.md
- .opencode/skills/openspec-archive-change
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
map: .opencode/skills/openspec-archive-change/.pi-map.md
- .opencode/skills/openspec-explore
index: .opencode/skills/openspec-explore/.pi-map.index.md
map: .opencode/skills/openspec-explore/.pi-map.md
- .opencode/skills/openspec-propose
index: .opencode/skills/openspec-propose/.pi-map.index.md
map: .opencode/skills/openspec-propose/.pi-map.md
## files
## links
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## workflows
-
## dirty
-
-18
View File
@@ -1,18 +0,0 @@
# .opencode/skills
dir: .opencode/skills
index: .opencode/skills/.pi-map.index.md
## role
Contains custom AI agent skills or capabilities that extend the base functionality of the OpenCode framework with specialized tools and behaviors.
## files
## arch
Modular skill plugin architecture where individual capabilities are registered as discrete, composable units that can be dynamically loaded and invoked by the agent system.
## tags
-
## symbols
-
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-apply-change (index)
dir: .opencode/skills/openspec-apply-change
## role
Provides an AI assistant skill that automates implementation of OpenSpec specification changes through a structured, CLI-driven workflow.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
map: .opencode/skills/openspec-apply-change/.pi-map.md
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-apply-change
dir: .opencode/skills/openspec-apply-change
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
## role
Provides an AI assistant skill that automates implementation of OpenSpec specification changes through a structured, CLI-driven workflow.
## files
- SKILL.md | Defines an AI assistant skill for implementing tasks from an OpenSpec change using a spec-driven workflow with the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, filesystem access
## arch
Skill-based plugin architecture using markdown-defined capabilities with structured workflow steps (analyze, plan, implement, verify) and external CLI tool integration.
## tags
skill, openspec, defines, assistant, implementing, tasks, change, spec
## symbols
-
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-archive-change (index)
dir: .opencode/skills/openspec-archive-change
## role
Defines an automation skill for archiving completed changes in an experimental workflow using the openspec CLI.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
map: .opencode/skills/openspec-archive-change/.pi-map.md
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-archive-change
dir: .opencode/skills/openspec-archive-change
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
## role
Defines an automation skill for archiving completed changes in an experimental workflow using the openspec CLI.
## files
- SKILL.md | Defines a skill for archiving completed changes in an experimental workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, openspec-sync-specs skill
## arch
Skill-based plugin architecture following a declarative markdown skill definition pattern for CLI workflow automation.
## tags
skill, openspec, defines, archiving, completed, changes, experimental, workflow
## symbols
-
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-explore (index)
dir: .opencode/skills/openspec-explore
## role
Provides a conversational exploration skill for OpenSpec CLI that enables non-implementation ideation, problem investigation, and requirements clarification.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-explore/.pi-map.index.md
map: .opencode/skills/openspec-explore/.pi-map.md
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-explore
dir: .opencode/skills/openspec-explore
index: .opencode/skills/openspec-explore/.pi-map.index.md
## role
Provides a conversational exploration skill for OpenSpec CLI that enables non-implementation ideation, problem investigation, and requirements clarification.
## files
- SKILL.md | Defines a conversational "explore mode" skill for OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
## arch
Skill-based plugin architecture using markdown-defined behavioral specifications with conversational AI patterns, separating exploration/thinking workflows from implementation tasks.
## tags
skill, defines, conversational, explore, mode, openspec, cli, acts
## symbols
-
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-propose (index)
dir: .opencode/skills/openspec-propose
## role
Automates the creation of structured software change proposals through a scaffolded directory workflow using the openspec CLI.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-propose/.pi-map.index.md
map: .opencode/skills/openspec-propose/.pi-map.md
## workflows
-
## dirty
-
@@ -1,19 +0,0 @@
# .opencode/skills/openspec-propose
dir: .opencode/skills/openspec-propose
index: .opencode/skills/openspec-propose/.pi-map.index.md
## role
Automates the creation of structured software change proposals through a scaffolded directory workflow using the openspec CLI.
## files
- SKILL.md | Defines an AI skill that automates proposing new software changes by scaffolding a directory structure and generating dependent artifacts (proposal, design, tasks) using the openspec CLI workflow. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
## arch
Template-driven skill architecture following a sequential artifact generation pattern (proposal → design → tasks) with CLI-based workflow automation.
## tags
skill, defines, automates, proposing, new, software, changes, scaffolding
## symbols
-
## workflows
-
## dirty
-
-79
View File
@@ -1,79 +0,0 @@
# . (index)
dir: .
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
A self-hosted web platform for managing projects, git repositories, and development tools with OAuth2 authentication, built as a Dockerized multi-service application.
## parent
-
## children
- .atl
index: .atl/.pi-map.index.md
map: .atl/.pi-map.md
- .claude
index: .claude/.pi-map.index.md
map: .claude/.pi-map.md
- .opencode
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
- .pi
index: .pi/.pi-map.index.md
map: .pi/.pi-map.md
- .sisyphus
index: .sisyphus/.pi-map.index.md
map: .sisyphus/.pi-map.md
- .stoneforge
index: .stoneforge/.pi-map.index.md
map: .stoneforge/.pi-map.md
- apps
index: apps/.pi-map.index.md
map: apps/.pi-map.md
- docs
index: docs/.pi-map.index.md
map: docs/.pi-map.md
- e2e
index: e2e/.pi-map.index.md
map: e2e/.pi-map.md
- openspec
index: openspec/.pi-map.index.md
map: openspec/.pi-map.md
- scripts
index: scripts/.pi-map.index.md
map: scripts/.pi-map.md
- tool-images
index: tool-images/.pi-map.index.md
map: tool-images/.pi-map.md
## files
- .env.example
- .gitignore
- AGENTS.md
- CHANGELOG.md
- Makefile
- README.md
- docker-compose.traefik.yml
- docker-compose.yml
- progress.md
- swap-pane
- ui-audit-spacing-typography.md
- ui-rework-foundations-apply.md
- ui-rework-pass2-apply.md
## links
index: ./.pi-map.index.md
map: ./.pi-map.md
## workflows
-
## dirty
-
-45
View File
@@ -1,45 +0,0 @@
# .
dir: .
index: ./.pi-map.index.md
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
A self-hosted web platform for managing projects, git repositories, and development tools with OAuth2 authentication, built as a Dockerized multi-service application.
## files
- .env.example | Template environment configuration file defining all required and optional environment variables for a multi-service application stack
- .gitignore | Specifies patterns for files and directories that Git should ignore in this project. | dep: git
- AGENTS.md | Defines agent behavior rules, workflow procedures, and project conventions for AI agents working on an OpenSpec-driven codebase. | dep: OpenSpec, git, superpowers workflow system, conventional commits
- CHANGELOG.md | Documents version history and notable changes to a project management and Git repository application
- Makefile | Provides standard development commands for managing a Docker Compose-based multi-service application (API, web, Postgres, Redis) with testing, migration, linting, and build automation. | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm/node
- README.md | A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication. | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik
- docker-compose.traefik.yml | Deploys a multi-service web application stack (frontend, API, PostgreSQL, Redis) behind an existing Traefik reverse proxy with automatic HTTPS | dep: Docker, Docker Compose, Traefik, PostgreSQL, Redis, Authentik
- docker-compose.yml | Defines a multi-container Docker application with PostgreSQL, Redis, API backend, and web frontend services for a "headquarter" application. | dep: Docker Compose, PostgreSQL 15, Redis 7, Python/FastAPI (implied by asyncpg), Vite/Node.js (implied by web build), Alpine Linux
- progress.md | Documents the completed phases and remaining work of a backend-frontend code refactoring project involving modularization, subpackage extraction, and file reorganization.
- swap-pane | Swaps two tmux panes between windows, preserving active pane state and layout | dep: tmux
- ui-audit-spacing-typography.md | Documents a comprehensive UI audit of spacing, typography, and visual rhythm issues in a React web application, identifying missing CSS classes, unstyled mobile components, inconsistent design tokens, and layout bugs across component and stylesheet files. | dep: React/TSX components, CSS modules/stylesheets, CSS custom properties (tokens), mobile-specific components, design system tokens
- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including token expansion, primitive CSS classes, component updates, and validation results. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, git/SSH, npm build toolchain
- ui-rework-pass2-apply.md | Documents a completed web UI refactoring pass that converted inline styles to token-based utility classes, unified form patterns, and added component utilities across multiple React components. | dep: React, CSS custom properties, OpenSpec, Git, npm, TypeScript, ESLint
## arch
Full-stack monolithic architecture with React frontend, API backend, PostgreSQL and Redis services, containerized via Docker Compose, using Traefik for reverse proxy/HTTPS, and following a token-based design system for UI consistency.
## tags
docker, application, git, web, react, ui, documents, rework
## symbols
-
## workflows
-
## dirty
-
-23
View File
@@ -1,23 +0,0 @@
# .stoneforge (index)
dir: .stoneforge
## role
Infrastructure configuration and metadata storage for the Stoneforge tool
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
- .stoneforge/sync
index: .stoneforge/sync/.pi-map.index.md
map: .stoneforge/sync/.pi-map.md
## files
- .dashboard-opened
- .gitignore
- config.yaml
## links
index: .stoneforge/.pi-map.index.md
map: .stoneforge/.pi-map.md
## workflows
-
## dirty
-
-21
View File
@@ -1,21 +0,0 @@
# .stoneforge
dir: .stoneforge
index: .stoneforge/.pi-map.index.md
## role
Infrastructure configuration and metadata storage for the Stoneforge tool
## files
- .dashboard-opened | Stores timestamp metadata for tracking when a dashboard was opened
- .gitignore | Specifies files and patterns for Git to ignore in the repository | dep: git
- config.yaml | YAML configuration file defining settings for the Stoneforge tool, including database, sync, playbooks, identity, merge, workflow, and agent permissions.
## arch
Dot-directory pattern for tool-specific state, using YAML for hierarchical configuration and dotfiles for ephemeral runtime metadata
## tags
git, config, stores, timestamp, metadata, tracking, dashboard, was
## symbols
-
## workflows
-
## dirty
-
-20
View File
@@ -1,20 +0,0 @@
# .stoneforge/sync (index)
dir: .stoneforge/sync
## role
Provides persistent storage for entity relationships and AI worker agent records in a synchronization system.
## parent
index: .stoneforge/.pi-map.index.md
map: .stoneforge/.pi-map.md
## children
-
## files
- dependencies.jsonl
- elements.jsonl
## links
index: .stoneforge/sync/.pi-map.index.md
map: .stoneforge/sync/.pi-map.md
## workflows
-
## dirty
-
-20
View File
@@ -1,20 +0,0 @@
# .stoneforge/sync
dir: .stoneforge/sync
index: .stoneforge/sync/.pi-map.index.md
## role
Provides persistent storage for entity relationships and AI worker agent records in a synchronization system.
## files
- dependencies.jsonl | Stores dependency relationships between entities in JSONL format, including parent-child, blocking, and reply-to relationships with timestamps and creators.
- elements.jsonl | Stores JSONL-formatted entity records for AI worker agents with their session histories and metadata in a data persistence file.
## arch
Append-only JSONL document store with separate files for dependency graph edges (dependencies.jsonl) and entity nodes (elements.jsonl), using a log-structured data pattern for time-ordered, immutable record keeping.
## tags
stores, relationships, jsonl, dependencies, elements, dependency, entities, format
## symbols
-
## workflows
-
## dirty
-
-1
View File
@@ -75,7 +75,6 @@ Do not:
* Introduce new dependencies without clear justification.
* Treat existing code as more authoritative than OpenSpec for intended behavior.
* Decide product behavior silently when the spec is unclear.
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
If scope must change, propose an OpenSpec update first.
-23
View File
@@ -1,23 +0,0 @@
# apps (index)
dir: apps
## role
Contains the main application entry points and executable modules for the project.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
- apps/api
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
- apps/web
index: apps/web/.pi-map.index.md
map: apps/web/.pi-map.md
## files
## links
index: apps/.pi-map.index.md
map: apps/.pi-map.md
## workflows
-
## dirty
-
-18
View File
@@ -1,18 +0,0 @@
# apps
dir: apps
index: apps/.pi-map.index.md
## role
Contains the main application entry points and executable modules for the project.
## files
## arch
Modular application structure with separate deployable units, likely following microservices or multi-app monorepo pattern with shared infrastructure.
## tags
-
## symbols
-
## workflows
-
## dirty
-
-42
View File
@@ -1,42 +0,0 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
.venv/
venv/
env/
# Test artifacts
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Git
.git/
.gitignore
# Local env files
.env
.env.local
# Alembic cache
alembic/versions/__pycache__/
# Pi lens cache
.pi-lens/
# Documentation
docs/
*.md
# Scripts not needed in container
scripts/
-39
View File
@@ -1,39 +0,0 @@
# apps/api (index)
dir: apps/api
## role
FastAPI-based backend API service for managing projects, git repositories, and development tools in a self-hosted development platform.
## parent
index: apps/.pi-map.index.md
map: apps/.pi-map.md
## children
- apps/api/.pi-lens
index: apps/api/.pi-lens/.pi-map.index.md
map: apps/api/.pi-lens/.pi-map.md
- apps/api/alembic
index: apps/api/alembic/.pi-map.index.md
map: apps/api/alembic/.pi-map.md
- apps/api/src
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
- apps/api/tests
index: apps/api/tests/.pi-map.index.md
map: apps/api/tests/.pi-map.md
- apps/api/uploads
index: apps/api/uploads/.pi-map.index.md
map: apps/api/uploads/.pi-map.md
## files
- .dockerignore
- Dockerfile
- README.md
- alembic.ini
- pyproject.toml
- uv.lock
- wait-for-db.sh
## links
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
## workflows
-
## dirty
-
-25
View File
@@ -1,25 +0,0 @@
# apps/api
dir: apps/api
index: apps/api/.pi-map.index.md
## role
FastAPI-based backend API service for managing projects, git repositories, and development tools in a self-hosted development platform.
## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance. | dep: Docker
- Dockerfile | Multi-stage Docker image for a Python web application with Docker-in-Docker capabilities, database waiting, and Cloudflare tunnel support | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
- README.md | README documentation for Headquarter API, a self-hosted FastAPI backend for managing projects, git repositories, and development tools. | dep: FastAPI, SQLAlchemy, PostgreSQL, asyncpg, Alembic, Docker, Docker Compose, Authentik, uvicorn, pytest, ruff, mypy
- alembic.ini | Configuration file for Alembic database migration tool specifying script location, database connection URL, and logging settings | dep: alembic, sqlalchemy, asyncpg, postgresql
- pyproject.toml | Defines Python project configuration, dependencies, and tool settings for a FastAPI-based backend API service | dep: fastapi, uvicorn, sqlalchemy, asyncpg, alembic, pydantic, pydantic-settings, python-multipart, httpx, structlog, cryptography, pytest, pytest-asyncio, mypy, ruff, aiosqlite
- uv.lock | Lock file generated by uv package manager that records exact dependency versions, hashes, and download URLs for reproducible Python environment installation | dep: uv, PyPI, python
- wait-for-db.sh | Waits for a PostgreSQL database to become available before executing the provided command | dep: nc (netcat), sh, sleep
## arch
Modern Python backend with FastAPI, SQLAlchemy/ORM with Alembic migrations, uv for dependency management, multi-stage Docker containerization with Docker-in-Docker support, and PostgreSQL database connectivity.
## tags
docker, alembic, python, database, fastapi, uvicorn, pyproject, readme
## symbols
-
## workflows
-
## dirty
-
+2 -2
View File
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
# Copy application code
COPY --chown=appuser:appgroup . .
# Create directories for repo, instance, and workspace storage
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
# Create directories for repo and instance storage
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
# Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
-25
View File
@@ -1,25 +0,0 @@
# apps/api/alembic (index)
dir: apps/api/alembic
## role
Database migration infrastructure for the API application, enabling version-controlled schema changes with async database support.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
## children
- apps/api/alembic/versions
index: apps/api/alembic/versions/.pi-map.index.md
map: apps/api/alembic/versions/.pi-map.md
## files
- env.py
- script.py.mako
## links
index: apps/api/alembic/.pi-map.index.md
map: apps/api/alembic/.pi-map.md
## workflows
- change alembic behavior
read: env.py, script.py.mako
- explore alembic subdirectories
index: apps/api/alembic/versions/.pi-map.index.md
## dirty
-
-30
View File
@@ -1,30 +0,0 @@
# apps/api/alembic
dir: apps/api/alembic
index: apps/api/alembic/.pi-map.index.md
## role
Database migration infrastructure for the API application, enabling version-controlled schema changes with async database support.
## files
- env.py | Configures Alembic database migration environment with async SQLAlchemy support | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQL schema revisions | dep: alembic, sqlalchemy
## arch
Template-based migration generation using Alembic with async SQLAlchemy engine configuration and environment setup.
## tags
migrations, run, async, sqlalchemy, alembic, call:context.configure, call:context.begin, transaction
## symbols
- run_migrations_offline
- do_run_migrations
- run_async_migrations
- run_migrations_online
- call:context.configure
- call:context.begin_transaction
- call:context.run_migrations
- call:async_engine_from_config
## workflows
- change alembic behavior
read: env.py, script.py.mako
- explore alembic subdirectories
index: apps/api/alembic/versions/.pi-map.index.md
## dirty
-
@@ -1,73 +0,0 @@
# apps/api/alembic/versions (index)
dir: apps/api/alembic/versions
## role
Manages database schema evolution and version control for the API application using Alembic migration scripts.
## parent
index: apps/api/alembic/.pi-map.index.md
map: apps/api/alembic/.pi-map.md
## children
-
## files
- 0001_initial_schema.py
- 0002_refresh_tokens.py
- 0003_user_configs.py
- 0004_tool_types.py
- 0005_ssh_keys_timestamps.py
- 0006_tool_instances.py
- 0007_instance_container_name.py
- 0008_tool_type_category.py
- 0009_tool_configs.py
- 0010_tool_type_default_port.py
- 0011_tool_instance_tunnel_fields.py
- 0012_default_port_req.py
- 0013_add_config_profiles.py
- 0013_add_probe_result.py
- 0014_add_profile_resolver_fields.py
- 0014_merge_heads.py
- 0015_single_interface.py
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py
- 20260527160017_add_pi_agent_tool_type.py
- 2026_05_22_add_clone_mode.py
- 2026_05_23_remove_is_builtin.py
- 2026_05_24_220141_add_startup_command.py
- 2026_05_24_add_config_profiles.py
- 2026_05_26_add_git_mounts.py
- 2026_05_27_external_repos.py
- 2026_05_28_add_monitoring_tables.py
- 2026_05_28_add_terminal_sessions_table.py
- 2026_05_28_add_tool_definition_manifests.py
- 2026_05_28_drop_tool_configs_and_config_folders.py
- 2026_05_29_add_notifications_table.py
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py
- 2026_05_29_fix_code_server_bind_addr.py
- 2026_05_29_fix_code_server_bind_addr_port.py
- 2026_05_29_fix_web_tool_bind_address.py
- 2026_05_29_remove_lsio_command_override.py
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py
- 2026_06_01_add_workspaces.py
- 2026_06_13_make_clone_mode_nullable.py
- 2026_06_14_104415_add_tool_type_home_directory.py
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
- 398082499c30_add_tool_config_fields.py
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py
- 8ed7dd80973d_create_config_folders_table.py
- af8512103d67_add_tool_type_fields.py
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py
## links
index: apps/api/alembic/versions/.pi-map.index.md
map: apps/api/alembic/versions/.pi-map.md
## workflows
- change versions behavior
read: 0001_initial_schema.py, 0002_refresh_tokens.py, 0003_user_configs.py
- change versions CLI
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
- change versions config
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
## dirty
-
-80
View File
@@ -1,80 +0,0 @@
# apps/api/alembic/versions
dir: apps/api/alembic/versions
index: apps/api/alembic/versions/.pi-map.index.md
## role
Manages database schema evolution and version control for the API application using Alembic migration scripts.
## files
- 0001_initial_schema.py | Creates the initial database schema with five tables (users, ssh_keys, projects, git_repositories, user_configs) using Alembic migrations | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication/session management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
- 0003_user_configs.py | Alembic database migration that creates a user_configs table with JSON configuration storage linked to users via foreign key | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.JSON, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
- 0004_tool_types.py | Alembic database migration that creates a tool_types table with metadata, templates, and user relationship columns | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
- 0005_ssh_keys_timestamps.py | Alembic database migration that adds created_at and updated_at timestamp columns to the ssh_keys table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 0006_tool_instances.py | Alembic database migration that creates a tool_instances table with UUID primary key, foreign key relationships, timestamps, and indexes for tracking deployed tool instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Integer, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
- 0007_instance_container_name.py | Alembic database migration that adds a nullable container_name column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 0008_tool_type_category.py | Alembic database migration that adds 'category' and 'interfaces' columns to the 'tool_types' table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 0009_tool_configs.py | Alembic database migration that creates a tool_configs table for storing user tool configuration key-value pairs with optional project scoping | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 0010_tool_type_default_port.py | Alembic database migration that adds a nullable default_port column to the tool_types table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 0011_tool_instance_tunnel_fields.py | Alembic database migration that adds public_url and tunnel_id columns to the tool_instances table for tunnel functionality. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 0012_default_port_req.py | Alembic database migration that backfills default_port values for existing tool types and makes the column non-nullable | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.Integer, func:downgrade() → None, call:op.alter_column, call:sa.Integer | dep: typing, alembic, sqlalchemy
- 0013_add_config_profiles.py | Database migration that adds config profiles, profile includes, profile mounts, and tool instance profile selection tables with defensive idempotent checks | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:upgrade() → None, call:_table_exists, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:_index_exists, call:op.create_index, call:sa.Integer, call:_column_exists, call:op.add_column, call:_foreign_key_exists, call:op.create_foreign_key, func:downgrade() → None, call:op.drop_index, call:op.drop_constraint, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 0013_add_probe_result.py | Alembic database migration that adds a JSON probe_result column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 0014_add_profile_resolver_fields.py | Alembic database migration that adds profile resolver fields (project_id, tool_type_id, environment_variables, start_command, working_directory, port, is_default) to config_profiles table, renames mount_path to target_path and adds mode/files columns to config_mounts table while removing source_profile_id and content columns | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:_foreign_key_names_for_column(table_name: str, column_name: str) → list[str], call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, call:names.append, func:upgrade() → None, call:_column_exists, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.JSON, call:sa.Text, call:sa.Integer, call:sa.Boolean, call:_foreign_key_exists, call:op.create_foreign_key, call:_index_exists, call:op.create_index, call:op.alter_column, call:sa.String, call:_foreign_key_names_for_column, call:op.drop_constraint, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.Text, call:op.drop_column, call:op.alter_column, call:op.drop_index, call:op.drop_constraint | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 0014_merge_heads.py | Alembic merge migration that combines two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
- 0015_single_interface.py | Alembic database migration that replaces a plural JSON interfaces column with singular interface_type and requires_port columns on tool_types table | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds an ssh_key_id foreign key column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a new "pi-agent" tool type with terminal-based Docker development environment configuration | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
- 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories and clone_mode/branch columns to tool_instances | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 2026_05_23_remove_is_builtin.py | Alembic database migration that removes the `is_builtin` column from the `tool_types` table with downgrade support to restore it. | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 2026_05_24_add_config_profiles.py | Alembic database migration that creates config_profiles and config_profile_includes tables with indexes, and adds a selected_config_profile_id foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:sa.Integer, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 2026_05_26_add_git_mounts.py | Alembic database migration that adds a git_mounts JSON column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
- 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories to support external repositories and expands alembic_version version_num column to prevent truncation. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
- 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health check status. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with foreign key to tool_instances for tracking terminal session lifecycle | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest support to tool_types and tool_instances, and migrates the pi-agent tool from Dockerfile/compose templates to a JSON-based manifest system with a base Ubuntu image definition. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
- 2026_05_28_drop_tool_configs_and_config_folders.py | Database migration that drops the `tool_configs` and `config_folders` tables with conditional existence checks, and provides downgrade to recreate them. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
- 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, and severity-based notification records including read/dismissed tracking and optimized indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON ssh_key_ids column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py | Alembic database migration that removes the ssh_key_id column from the config_profiles table | exp: func:upgrade() → None, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey | dep: alembic, sqlalchemy
- 2026_05_29_fix_code_server_bind_addr.py | Alembic database migration that fixes code-server tool type compose templates by replacing deprecated --bind-addr flag with --host flag | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--bind-addr%' """) ).fetchall, call:sa.text, call:compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" ).replace, call:print, func:downgrade() → None | dep: typing, alembic, sqlalchemy
- 2026_05_29_fix_code_server_bind_addr_port.py | Alembic database migration that fixes code-server Docker compose templates by replacing broken `--host` flags with correct `--bind-addr 0.0.0.0:{port}` configurations in both database tool_type templates and on-disk instance compose files. | exp: func:_fix_tool_type_templates(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, default_port FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--host%' """) ).fetchall, call:sa.text, call:compose_template.split, call:len, call:line.lstrip, call:new_lines.append, call:"\n".join, call:print, func:_fix_instance_compose_files(conn) → None, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:sa.text, call:print, call:conn.execute( sa.text(""" SELECT id, compose_path, tool_type_id FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:conn.execute( sa.text(""" SELECT default_port FROM tool_types WHERE id = :id """), {"id": tool_type_id}, ).fetchone, call:yaml.safe_load, call:data["services"].values, call:path.write_text, call:yaml.dump, func:upgrade() → None, call:op.get_bind, call:_fix_tool_type_templates, call:_fix_instance_compose_files, func:downgrade() → None | dep: typing, alembic, yaml, pathlib, sqlalchemy
- 2026_05_29_fix_web_tool_bind_address.py | Alembic database migration that fixes code-server and jupyter-notebook tool compose templates to bind to 0.0.0.0 | exp: func:_fix_code_server_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'code-server' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:len, call:line.lstrip, call:new_lines.append, call:image_line.lstrip, call:new_lines.index, call:new_lines.insert, call:"\n".join, call:print, func:_fix_jupyter_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'jupyter-notebook' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:new_lines.append, call:len, call:line.lstrip, call:"\n".join, call:print, func:upgrade() → None, call:op.get_bind, call:_fix_code_server_compose, call:_fix_jupyter_compose, func:downgrade() → None | dep: typing, alembic, sqlalchemy
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database records and on-disk compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (and can restore) the ssh_keys mount from the pi-agent tool definition manifest stored as JSON | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
- 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with indexes and adds a workspace_id foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_06_13_make_clone_mode_nullable.py | Alembic database migration to make the `clone_mode` column nullable in the `tool_instances` table | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
- 2026_06_14_104415_add_tool_type_home_directory.py | Alembic database migration that adds a home_directory column to tool_types table and updates template strings from /workspace to a configurable path | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, func:downgrade() → None, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, call:op.drop_column | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py | Alembic database migration that updates the pi-agent tool definition manifest to mount repos under the home directory instead of /workspace | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py | Alembic database migration that removes explicit repo mounts from pi-agent tool definition manifests since they are now synthesized by compile_compose | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:any, call:mount.get, call:mounts.append, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds configuration fields (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two branches (removing is_builtin and adding config_profiles) with no actual schema changes | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Alembic database migration that merges two divergent migration branches (profile resolver and workspaces) into a single head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes pi_state and pi_config mounts from the pi-agent manifest in upgrade, and restores them in downgrade | exp: func:_load_manifest(manifest_json), call:isinstance, call:json.loads, func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:m.get, call:mounts.append, call:json.dumps | dep: json, alembic, sqlalchemy
- 8ed7dd80973d_create_config_folders_table.py | Creates a database migration that adds a config_folders table for storing user configuration folders with JSONB metadata, foreign key relationships, and indexing. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds new columns (definition_type, dockerfile_template, build_context, readiness_probe) to the tool_types table with a CHECK constraint on definition_type. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two parent revisions (single_interface and clone_mode) into a single branch point with no actual schema changes | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py | Alembic database migration that merges two branch heads (home directory and pi agent mount cleanup) into a single revision point | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
## arch
Linear migration history with branching/merge resolution pattern; each migration is an immutable, timestamped or sequenced script containing declarative SQLAlchemy operations (create_table, add_column, drop_column) with bidirectional upgrade/downgrade functions; includes data migrations, idempotent guards, and cross-references to on-disk file mutations.
## tags
column, table, call:op.drop, downgrade, upgrade, alembic, key, call:sa.text
## symbols
- upgrade
- downgrade
- _table_exists
- _column_exists
- _index_exists
- _foreign_key_exists
- _foreign_key_names_for_column
- _get_dialect
## workflows
- change versions behavior
read: 0001_initial_schema.py, 0002_refresh_tokens.py, 0003_user_configs.py
- change versions CLI
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
- change versions config
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
## dirty
-
@@ -1,204 +0,0 @@
"""add config profiles, includes, mounts, and tool instance profile selection
Revision ID: 0013_add_config_profiles
Revises: 0012_default_port_req
Create Date: 2026-05-24 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_config_profiles"
down_revision: str | None = "0012_default_port_req"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def upgrade() -> None:
# Earlier branches may already have created config_profiles. Keep this
# migration defensive so databases can converge onto the current graph.
if not _table_exists("config_profiles"):
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id", "name", name="uq_config_profiles_user_name"
),
)
if not _index_exists("config_profiles", "idx_config_profiles_user"):
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
if not _table_exists("config_includes"):
op.create_table(
"config_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
"included_profile_id", postgresql.UUID(as_uuid=True), nullable=False
),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["included_profile_id"],
["config_profiles.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"profile_id", "included_profile_id", name="uq_config_includes_pair"
),
)
if not _index_exists("config_includes", "idx_config_includes_profile"):
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
if not _index_exists("config_includes", "idx_config_includes_included"):
op.create_index(
"idx_config_includes_included", "config_includes", ["included_profile_id"]
)
if not _table_exists("config_mounts"):
op.create_table(
"config_mounts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("mount_path", sa.String(length=1024), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
if not _index_exists("config_mounts", "idx_config_mounts_profile"):
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
if not _column_exists("tool_instances", "selected_profile_id"):
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _foreign_key_exists(
"tool_instances", ["selected_profile_id"], "config_profiles"
):
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"):
op.create_index(
"idx_tool_instances_selected_profile",
"tool_instances",
["selected_profile_id"],
)
def downgrade() -> None:
# Remove selected_profile_id from tool_instances
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
op.drop_constraint(
"fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey"
)
op.drop_column("tool_instances", "selected_profile_id")
# Drop config_mounts
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
op.drop_table("config_mounts")
# Drop config_includes
op.drop_index("idx_config_includes_included", table_name="config_includes")
op.drop_index("idx_config_includes_profile", table_name="config_includes")
op.drop_table("config_includes")
# Drop config_profiles
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -1,180 +0,0 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
Create Date: 2026-05-24 14:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_profile_resolver_fields"
down_revision: str | None = "0013_add_config_profiles"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]:
if not _table_exists(table_name):
return []
names: list[str] = []
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if column_name in foreign_key.get("constrained_columns", []):
name = foreign_key.get("name")
if name:
names.append(name)
return names
def upgrade() -> None:
if not _column_exists("config_profiles", "project_id"):
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "tool_type_id"):
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "environment_variables"):
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
if not _column_exists("config_profiles", "start_command"):
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "working_directory"):
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "port"):
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
if not _column_exists("config_profiles", "is_default"):
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
if not _index_exists("config_profiles", "idx_config_profiles_project"):
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
op.create_index(
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
)
if _column_exists("config_mounts", "mount_path") and not _column_exists(
"config_mounts", "target_path"
):
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
if not _column_exists("config_mounts", "mode"):
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
if not _column_exists("config_mounts", "files"):
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
for constraint_name in _foreign_key_names_for_column(
"config_mounts", "source_profile_id"
):
op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey")
if _column_exists("config_mounts", "content"):
op.drop_column("config_mounts", "content")
if _column_exists("config_mounts", "source_profile_id"):
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_constraint(
"fk_config_profiles_tool_type", "config_profiles", type_="foreignkey"
)
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
@@ -104,11 +104,11 @@ RUN git config --global init.defaultBranch main \\
&& git config --global user.name "Developer"
# Create default tmux config
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \\
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
@@ -232,6 +232,20 @@ def upgrade() -> None:
"writable": True,
"owner": "user",
},
{
"name": "pi_state",
"target": "/tmp/.pi/agents",
"source_type": "instance",
"writable": True,
},
{
"name": "pi_config",
"target": "/home/user/.pi",
"source_type": "git_mount",
"git_mount_ref": "dotfiles",
"writable": True,
"owner": "user",
},
],
"runtime": {
"command": ["/bin/bash"],
@@ -308,10 +322,10 @@ RUN git config --global init.defaultBranch main \\
&& git config --global user.email "dev@headquarter.local" \\
&& git config --global user.name "Developer"
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN mkdir -p /home/user/.config/ranger \\
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
RUN mkdir -p /home/user/.pi/agent
@@ -1,81 +0,0 @@
"""add workspaces table
Revision ID: 2026_06_01_add_workspaces
Revises: 2026_05_29_fix_code_server_bind_addr_port
Create Date: 2026-06-01 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_06_01_add_workspaces"
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Create workspaces table
op.create_table(
"workspaces",
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"repo_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
sa.Column("path", sa.String(2048), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
if_not_exists=True,
)
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
op.create_index("idx_workspaces_status", "workspaces", ["status"])
# Add workspace_id to tool_instances
op.add_column(
"tool_instances",
sa.Column(
"workspace_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
)
def downgrade() -> None:
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
op.drop_column("tool_instances", "workspace_id")
op.drop_table("workspaces")
@@ -1,35 +0,0 @@
"""make clone_mode nullable
Revision ID: 2026_06_13_make_clone_mode_nullable
Revises: 86cec91fdb00
Create Date: 2026-06-13 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "2026_06_13_make_clone_mode_nullable"
down_revision = "86cec91fdb00"
branch_labels = None
depends_on = None
def upgrade() -> None:
# The workspace-first cleanup no longer writes clone_mode; existing rows
# keep their value, but new rows may be NULL.
op.alter_column(
"tool_instances",
"clone_mode",
existing_type=sa.String(20),
nullable=True,
)
def downgrade() -> None:
op.alter_column(
"tool_instances",
"clone_mode",
existing_type=sa.String(20),
nullable=False,
)
@@ -1,89 +0,0 @@
"""add_tool_type_home_directory
Revision ID: 2026_06_14_104415
Revises: f3d2dc90ba3a
Create Date: 2026-06-14 10:44:15.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_14_104415"
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_types = table(
"tool_types",
column("id", sa.UUID),
column("home_directory", sa.String),
column("compose_template", sa.Text),
column("dockerfile_template", sa.Text),
)
OLD_WORKSPACE = "/workspace"
NEW_WORKSPACE = "/home/user/{{WORKSPACE_NAME}}"
def upgrade() -> None:
# Add the new column with a default that applies to existing rows.
op.add_column(
"tool_types",
sa.Column(
"home_directory",
sa.String(255),
nullable=False,
server_default="/home/user",
),
)
# Rewrite legacy templates that mount the workspace at /workspace so they
# use the new configurable home directory and preserve the workspace name.
op.execute(
sa.update(tool_types)
.where(tool_types.c.compose_template.is_not(None))
.values(
compose_template=sa.func.replace(
tool_types.c.compose_template, OLD_WORKSPACE, NEW_WORKSPACE
)
)
)
op.execute(
sa.update(tool_types)
.where(tool_types.c.dockerfile_template.is_not(None))
.values(
dockerfile_template=sa.func.replace(
tool_types.c.dockerfile_template, OLD_WORKSPACE, NEW_WORKSPACE
)
)
)
def downgrade() -> None:
# Restore the original /workspace strings before dropping the column.
op.execute(
sa.update(tool_types)
.where(tool_types.c.compose_template.is_not(None))
.values(
compose_template=sa.func.replace(
tool_types.c.compose_template, NEW_WORKSPACE, OLD_WORKSPACE
)
)
)
op.execute(
sa.update(tool_types)
.where(tool_types.c.dockerfile_template.is_not(None))
.values(
dockerfile_template=sa.func.replace(
tool_types.c.dockerfile_template, NEW_WORKSPACE, OLD_WORKSPACE
)
)
)
op.drop_column("tool_types", "home_directory")
@@ -1,90 +0,0 @@
"""fix_pi_agent_home_directory_mount
Revision ID: 2026_06_14_182955
Revises: fc8f1a20cbf6
Create Date: 2026-06-14 18:29:55.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_14_182955"
down_revision: Union[str, Sequence[str], None] = "fc8f1a20cbf6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_definition_manifests = table(
"tool_definition_manifests",
column("id", sa.UUID),
column("name", sa.String),
column("manifest", sa.JSON),
)
def _find_pi_agent_manifest(conn: sa.Connection) -> tuple[Union[str, None], Union[dict, None]]:
result = conn.execute(
sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest)
.where(tool_definition_manifests.c.name == "pi-agent")
).fetchone()
if result is None:
return None, None
return result.id, dict(result.manifest)
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
conn.execute(
sa.update(tool_definition_manifests)
.where(tool_definition_manifests.c.id == manifest_id)
.values(manifest=manifest)
)
def upgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
# Mount the repo under the configured home directory, preserving the repo
# directory name via the WORKSPACE_NAME runtime variable.
for mount in manifest.get("mounts", []):
if mount.get("source_type") == "repo":
mount["target"] = "~/{{WORKSPACE_NAME}}"
# Keep /workspace as a compatibility symlink to the real mount path.
runtime = manifest.setdefault("runtime", {})
runtime["working_dir"] = "/workspace"
# Update the startup script to chown the real mount path.
scripts = manifest.setdefault("scripts", {})
scripts["startup"] = [
'if [ -n "$WORKSPACE_NAME" ]; then sudo chown -R user:user "$HOME/$WORKSPACE_NAME" 2>/dev/null || true; fi',
]
_update_manifest(conn, manifest_id, manifest)
def downgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
for mount in manifest.get("mounts", []):
if mount.get("source_type") == "repo":
mount["target"] = "/workspace"
runtime = manifest.setdefault("runtime", {})
runtime["working_dir"] = "/workspace"
scripts = manifest.setdefault("scripts", {})
scripts["startup"] = [
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
]
_update_manifest(conn, manifest_id, manifest)
@@ -1,86 +0,0 @@
"""remove pi agent explicit repo mount
Revision ID: 2026_06_15_090500
Revises: 2026_06_14_182955
Create Date: 2026-06-15 09:05:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_15_090500"
down_revision: Union[str, Sequence[str], None] = "2026_06_14_182955"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_definition_manifests = table(
"tool_definition_manifests",
column("id", sa.UUID),
column("name", sa.String),
column("manifest", sa.JSON),
)
def _find_pi_agent_manifest(
conn: sa.Connection,
) -> tuple[Union[str, None], Union[dict, None]]:
result = conn.execute(
sa.select(
tool_definition_manifests.c.id, tool_definition_manifests.c.manifest
).where(tool_definition_manifests.c.name == "pi-agent")
).fetchone()
if result is None:
return None, None
return result.id, dict(result.manifest)
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
conn.execute(
sa.update(tool_definition_manifests)
.where(tool_definition_manifests.c.id == manifest_id)
.values(manifest=manifest)
)
def upgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
# The repo mount is now synthesized by compile_compose based on the
# instance's repository, so the manifest no longer needs an explicit
# repo mount with a {{WORKSPACE_NAME}} placeholder.
manifest["mounts"] = [
mount
for mount in manifest.get("mounts", [])
if mount.get("source_type") != "repo"
]
_update_manifest(conn, manifest_id, manifest)
def downgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
mounts = manifest.setdefault("mounts", [])
if not any(mount.get("source_type") == "repo" for mount in mounts):
mounts.append(
{
"name": "workspace",
"target": "~/{{WORKSPACE_NAME}}",
"source_type": "repo",
"writable": True,
"owner": "user",
}
)
_update_manifest(conn, manifest_id, manifest)
@@ -1,20 +0,0 @@
"""merge profile resolver and workspaces heads
Revision ID: 86cec91fdb00
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
Create Date: 2026-06-03 12:48:36.145702
"""
# revision identifiers, used by Alembic.
revision = "86cec91fdb00"
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,112 +0,0 @@
"""remove pi config and state mounts from pi-agent manifest
Revision ID: 8c6d1dbd4798
Revises: 2026_06_13_make_clone_mode_nullable
Create Date: 2026-06-13 11:21:25.983178
"""
import json
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8c6d1dbd4798'
down_revision = '2026_06_13_make_clone_mode_nullable'
branch_labels = None
depends_on = None
def _load_manifest(manifest_json):
return manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
def upgrade() -> None:
"""Remove pi_state and pi_config mounts from the pi-agent manifest."""
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
)
)
row = result.fetchone()
if not row:
return
manifest_id, manifest_json = row
manifest = _load_manifest(manifest_json)
mounts = manifest.get("mounts", [])
original_count = len(mounts)
filtered_mounts = [
m for m in mounts if m.get("name") not in ("pi_state", "pi_config")
]
if len(filtered_mounts) < original_count:
manifest["mounts"] = filtered_mounts
conn.execute(
sa.text(
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
),
{
"manifest": json.dumps(manifest),
"id": manifest_id,
},
)
def downgrade() -> None:
"""Restore pi_state and pi_config mounts to the pi-agent manifest."""
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
)
)
row = result.fetchone()
if not row:
return
manifest_id, manifest_json = row
manifest = _load_manifest(manifest_json)
mounts = manifest.get("mounts", [])
existing_names = {m.get("name") for m in mounts}
if "pi_state" not in existing_names:
mounts.append(
{
"name": "pi_state",
"target": "/tmp/.pi/agents",
"source_type": "instance",
"writable": True,
}
)
if "pi_config" not in existing_names:
mounts.append(
{
"name": "pi_config",
"target": "/home/user/.pi",
"source_type": "git_mount",
"git_mount_ref": "dotfiles",
"writable": True,
"owner": "user",
}
)
manifest["mounts"] = mounts
conn.execute(
sa.text(
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
),
{
"manifest": json.dumps(manifest),
"id": manifest_id,
},
)
@@ -1,23 +0,0 @@
"""merge home directory and pi agent mount cleanup heads
Revision ID: fc8f1a20cbf6
Revises: 2026_06_14_104415, 8c6d1dbd4798
Create Date: 2026-06-14 11:08:41.273502
"""
# revision identifiers, used by Alembic.
revision = 'fc8f1a20cbf6'
down_revision = ('2026_06_14_104415', '8c6d1dbd4798')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
-51
View File
@@ -1,51 +0,0 @@
# apps/api/src (index)
dir: apps/api/src
## role
Core backend API package for the "Headquarter API" FastAPI application, handling configuration, database setup, logging, and application bootstrap.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
## children
- apps/api/src/api
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
- apps/api/src/auth
index: apps/api/src/auth/.pi-map.index.md
map: apps/api/src/auth/.pi-map.md
- apps/api/src/models
index: apps/api/src/models/.pi-map.index.md
map: apps/api/src/models/.pi-map.md
- apps/api/src/schemas
index: apps/api/src/schemas/.pi-map.index.md
map: apps/api/src/schemas/.pi-map.md
- apps/api/src/scripts
index: apps/api/src/scripts/.pi-map.index.md
map: apps/api/src/scripts/.pi-map.md
- apps/api/src/seeds
index: apps/api/src/seeds/.pi-map.index.md
map: apps/api/src/seeds/.pi-map.md
- apps/api/src/services
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
- apps/api/src/utils
index: apps/api/src/utils/.pi-map.index.md
map: apps/api/src/utils/.pi-map.md
## files
- __init__.py
- config.py
- database.py
- logging_config.py
- main.py
## links
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
## workflows
- change src behavior
read: __init__.py, config.py, database.py
- change src config
read: config.py, logging_config.py
- explore src subdirectories
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/models/.pi-map.index.md
## dirty
-
-35
View File
@@ -1,35 +0,0 @@
# apps/api/src
dir: apps/api/src
index: apps/api/src/.pi-map.index.md
## role
Core backend API package for the "Headquarter API" FastAPI application, handling configuration, database setup, logging, and application bootstrap.
## files
- __init__.py | Marks the directory as a Python package for the Headquarter API.
- config.py | Defines application configuration settings with environment variable loading, database URL construction, and computed properties for service URLs and security settings. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
- database.py | Sets up an async SQLAlchemy database engine with retry logic and runs Alembic migrations via subprocess to initialize the database. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:os.path.dirname, call:os.path.abspath, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, os, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
- logging_config.py | Configures structured JSON logging with correlation ID injection, request/response logging middleware, and exception handling for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation, starlette
- main.py | Bootstraps a FastAPI application called "Headquarter API" with database initialization, health monitoring, CORS, logging middleware, and modular API routers. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api
## arch
Layered architecture with separation of concerns across config (settings/env), database (async SQLAlchemy with Alembic migrations), logging (structured JSON with middleware), and main (app composition with modular routers and health monitoring).
## tags
src, logging, database, api, call:logger.info, middleware, fastapi, filter
## symbols
- Settings
- CorrelationIdFilter
- JSONFormatter
- RequestLoggingMiddleware
- ExceptionLoggingMiddleware
- build_database_url
- init_database
- filter
## workflows
- change src behavior
read: __init__.py, config.py, database.py
- change src config
read: config.py, logging_config.py
- explore src subdirectories
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/models/.pi-map.index.md
## dirty
-
-40
View File
@@ -1,40 +0,0 @@
# apps/api/src/api (index)
dir: apps/api/src/api
## role
Provides shared Pydantic validators and package initialization for API schema validation across the API layer.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
## children
- apps/api/src/api/config
index: apps/api/src/api/config/.pi-map.index.md
map: apps/api/src/api/config/.pi-map.md
- apps/api/src/api/project
index: apps/api/src/api/project/.pi-map.index.md
map: apps/api/src/api/project/.pi-map.md
- apps/api/src/api/system
index: apps/api/src/api/system/.pi-map.index.md
map: apps/api/src/api/system/.pi-map.md
- apps/api/src/api/tool
index: apps/api/src/api/tool/.pi-map.index.md
map: apps/api/src/api/tool/.pi-map.md
- apps/api/src/api/user
index: apps/api/src/api/user/.pi-map.index.md
map: apps/api/src/api/user/.pi-map.md
- apps/api/src/api/workspace
index: apps/api/src/api/workspace/.pi-map.index.md
map: apps/api/src/api/workspace/.pi-map.md
## files
- __init__.py
- shared_validators.py
## links
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## workflows
- change api behavior
read: __init__.py, shared_validators.py
- explore api subdirectories
index: apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md, apps/api/src/api/system/.pi-map.index.md
## dirty
-
-30
View File
@@ -1,30 +0,0 @@
# apps/api/src/api
dir: apps/api/src/api
index: apps/api/src/api/.pi-map.index.md
## role
Provides shared Pydantic validators and package initialization for API schema validation across the API layer.
## files
- __init__.py | Marks the directory as a Python package for API routers.
- shared_validators.py | Provides reusable Pydantic validators for API schema fields including mount paths, file dictionaries, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
## arch
Utility module pattern with reusable cross-cutting Pydantic validators for common API field types (mount paths, files, env vars, volumes).
## tags
validate, raise:value, error, call:isinstance, validators, mount, api, init
## symbols
- validate_mount_path
- validate_files
- validate_env_vars
- validate_volumes
- call:v.startswith
- raise:ValueError
- call:v.items
- call:path.startswith
## workflows
- change api behavior
read: __init__.py, shared_validators.py
- explore api subdirectories
index: apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md, apps/api/src/api/system/.pi-map.index.md
## dirty
-
+6 -1
View File
@@ -1 +1,6 @@
"""API routers package."""
from src.api.auth import router as auth_router
from src.api.events import router as events_router
from src.api.notifications import router as notifications_router
from src.api.users import router as users_router
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
-24
View File
@@ -1,24 +0,0 @@
# apps/api/src/api/config (index)
dir: apps/api/src/api/config
## role
Provides FastAPI API endpoints for managing user configuration settings and config profiles.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## children
-
## files
- __init__.py
- config_profiles.py
- user_config.py
## links
index: apps/api/src/api/config/.pi-map.index.md
map: apps/api/src/api/config/.pi-map.md
## workflows
- change config behavior
read: __init__.py, config_profiles.py, user_config.py
- change config config
read: config_profiles.py, user_config.py
## dirty
-
-31
View File
@@ -1,31 +0,0 @@
# apps/api/src/api/config
dir: apps/api/src/api/config
index: apps/api/src/api/config/.pi-map.index.md
## role
Provides FastAPI API endpoints for managing user configuration settings and config profiles.
## files
- __init__.py | Aggregates and exports config-related API routers for module-level access | dep: src.api.config.config_profiles, src.api.config.user_config
- config_profiles.py | FastAPI router providing CRUD endpoints for user-owned config profiles with include resolution, default profile management, and git URL validation. | exp: func:list_config_profiles(project_id, tool_type_id, current_user_id, session), call:select(ConfigProfile) .where(ConfigProfile.user_id == user_uuid) .options, call:selectinload, call:uuid.UUID, call:conditions.append, call:ConfigProfile.project_id.is_, call:ConfigProfile.tool_type_id.is_, call:query.where, call:or_, call:session.execute, call:result.scalars().all, call:profile_to_response, func:create_config_profile(data: ConfigProfileCreate, current_user_id, session), call:create_profile, call:logger.debug, call:profile_to_response, func:get_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:profile_to_response, raise:HTTPException, func:update_config_profile(profile_id: str, data: ConfigProfileUpdate, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:update_profile, call:logger.debug, call:profile_to_response, raise:HTTPException, func:delete_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:session.delete, call:session.commit, call:logger.debug, raise:HTTPException, func:update_profile_includes_endpoint(profile_id: str, data: ConfigProfileIncludeUpdate, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:update_includes, call:session.execute, call:select(ConfigProfileInclude).where, call:inc_result.scalars().all, call:logger.debug, call:profile_to_response, call:list, raise:HTTPException, func:preview_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:resolve_profile, call:resolved_profile_to_dict, raise:HTTPException, func:resolve_default_profile_endpoint(project_id, tool_type_id, current_user_id, session), call:resolve_default_profile, call:uuid.UUID, func:get_default_profiles_endpoint(user_id, session) → dict, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, func:set_default_profiles_endpoint(data: DefaultProfilesUpdate, user_id, session) → dict, call:validate_default_profiles, call:get_or_create_user_config, call:session.commit, call:session.refresh, func:get_default_profile_for_tool_type_endpoint(tool_type_id: str, user_id, session) → dict, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:user_config.default_profiles.get, func:validate_git_url_endpoint(data: ValidateGitUrlRequest, current_user_id, session) → ValidateGitUrlResponse, call:validate_git_url | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.schemas.config, src.services.config.config_profile_resolver, src.services.config.crud_service, src.services.config.resolver_service
- user_config.py | Provides FastAPI endpoints for retrieving and updating user configuration settings with automatic record creation. | exp: func:_get_or_create_config(session: AsyncSession, user_id: uuid.UUID) → UserConfig, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:UserConfig, call:session.add, call:session.commit, call:session.refresh, func:get_user_config(user_id, session) → UserConfigResponse, call:_get_user, call:_get_or_create_config, call:UserConfigResponse.model_validate, func:update_user_config(data: UserConfigUpdate, user_id, session) → UserConfigResponse, call:_get_user, call:_get_or_create_config, call:data.model_dump, call:logger.debug, call:session.commit, call:session.refresh, call:UserConfigResponse.model_validate | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.user
## arch
Modular router-based architecture using FastAPI with separate route modules for distinct config domains (profiles vs. user settings), CRUD patterns, and include resolution for hierarchical config.
## tags
config, profile, get, user, call:uuid.uuid, includes, or, default
## symbols
- list_config_profiles
- create_config_profile
- get_config_profile
- update_config_profile
- delete_config_profile
- update_profile_includes_endpoint
- preview_config_profile
- resolve_default_profile_endpoint
## workflows
- change config behavior
read: __init__.py, config_profiles.py, user_config.py
- change config config
read: config_profiles.py, user_config.py
## dirty
-
-6
View File
@@ -1,6 +0,0 @@
"""Config API routers module."""
from src.api.config.config_profiles import router as config_profiles_router
from src.api.config.user_config import router as user_config_router
__all__ = ["config_profiles_router", "user_config_router"]
-300
View File
@@ -1,300 +0,0 @@
"""Config profile API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
resolve_profile,
resolved_profile_to_dict,
)
from src.services.config.crud_service import (
create_profile,
get_or_create_user_config,
get_profile_with_includes,
profile_to_response,
update_includes,
update_profile,
validate_default_profiles,
)
from src.services.config.resolver_service import (
resolve_default_profile,
validate_git_url,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
tool_type_id: str | None = Query(
None, description="Filter by tool type compatibility"
),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""List config profiles, optionally filtered by compatibility."""
user_uuid = current_user_id
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.options(selectinload(ConfigProfile.includes))
)
if project_id or tool_type_id:
from sqlalchemy import or_
project_uuid = uuid.UUID(project_id) if project_id else None
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
conditions: list = []
conditions.append(
(ConfigProfile.project_id.is_(None))
& (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
conditions.append(ConfigProfile.project_id == project_uuid)
if tool_uuid:
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
if project_uuid and tool_uuid:
conditions.append(
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
query = query.where(or_(*conditions))
result = await session.execute(query)
profiles = result.scalars().all()
return [profile_to_response(p) for p in profiles]
@router.post(
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
)
async def create_config_profile(
data: ConfigProfileCreate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
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)
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
async def get_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Get a config profile by ID."""
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"
)
return profile_to_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
async def update_config_profile(
profile_id: str,
data: ConfigProfileUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update a config profile."""
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"
)
profile = await update_profile(session, profile, data)
logger.debug("Updated config profile %s", profile.id)
return profile_to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Delete a config profile."""
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"
)
await session.delete(profile)
await session.commit()
logger.debug("Deleted config profile %s", profile_id)
return None
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes_endpoint(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update the ordered includes for a config profile."""
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"
)
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
profile = await update_includes(session, profile, included_uuids, current_user_id)
inc_result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
direct_includes = inc_result.scalars().all()
logger.debug("Updated includes for config profile %s", profile.id)
return profile_to_response(profile, list(direct_includes))
@router.get("/{profile_id}/preview")
async def preview_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Preview the resolved output of a config profile."""
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"
)
try:
resolved = await resolve_profile(session, profile.id)
except ConfigProfileCycleError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
return resolved_profile_to_dict(resolved)
@router.get("/defaults/resolve")
async def resolve_default_profile_endpoint(
project_id: str = Query(..., description="Project ID"),
tool_type_id: str = Query(..., description="Tool type ID"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Resolve the default config profile for a project/tool combination."""
return await resolve_default_profile(
session,
current_user_id,
uuid.UUID(project_id),
uuid.UUID(tool_type_id),
)
@router.get("/defaults")
async def get_default_profiles_endpoint(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all default profile mappings for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
return {"default_profiles": user_config.default_profiles if user_config else {}}
@router.put("/defaults")
async def set_default_profiles_endpoint(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profile mappings for the current user."""
await validate_default_profiles(session, user_id, data.default_profiles)
user_config = await get_or_create_user_config(session, user_id)
user_config.config = {
**user_config.config,
"default_profiles": data.default_profiles,
}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type_endpoint(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the default profile ID for a specific tool type."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
async def validate_git_url_endpoint(
data: ValidateGitUrlRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ValidateGitUrlResponse:
"""Validate a git remote URL and list available branches."""
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import GitRepository
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models import SSHKey
from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -10,13 +10,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from src.auth.dependencies import get_current_user_id
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
router = APIRouter(prefix="/events", tags=["events"])
# In-memory connection counter per user (single-process assumption)
_connection_counts: dict[uuid.UUID, int] = {}
MAX_CONNECTIONS_PER_USER = 20
MAX_CONNECTIONS_PER_USER = 5
@router.get("/stream")
File diff suppressed because it is too large Load Diff
@@ -5,16 +5,10 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.database import SessionLocal
from src.schemas.system import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
router = APIRouter()
@@ -22,6 +16,45 @@ router = APIRouter()
_start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get(
"/health",
response_model=HealthResponse,
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ToolInstance
from src.models import ToolType
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
@@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.user import User
from src.models import UserConfig
from src.services.shared.notification_service import notification_service
from src.models.user_config import UserConfig
from src.services.notification_service import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"])
-22
View File
@@ -1,22 +0,0 @@
# apps/api/src/api/project (index)
dir: apps/api/src/api/project
## role
Provides FastAPI REST API endpoints for project and Git repository management, exposing CRUD operations and version control functionality to API consumers.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## children
-
## files
- __init__.py
- git_repositories.py
- projects.py
## links
index: apps/api/src/api/project/.pi-map.index.md
map: apps/api/src/api/project/.pi-map.md
## workflows
- change project behavior
read: __init__.py, git_repositories.py, projects.py
## dirty
-
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
"""Project API routers module."""
from src.api.project.git_repositories import router as git_repositories_router
from src.api.project.projects import router as projects_router
__all__ = ["git_repositories_router", "projects_router"]
@@ -1,500 +0,0 @@
import logging
import os
import shutil
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import GitRepository, SSHKey
from src.schemas.project.git_repository import (
BranchCreateRequest,
BranchesResponse,
CheckoutRequest,
CommitRequest,
CommitResponse,
FetchResponse,
FileContentResponse,
FileListResponse,
FileUpdateRequest,
FileUpdateResponse,
GitRepositoryCreate,
GitRepositoryResponse,
MergeRequest,
MergeResponse,
PullResponse,
PushResponse,
StatusResponse,
UpdateSSHKeyRequest,
URLParseRequest,
URLParseResponse,
)
from src.services.git.operations import (
clone_working_repository,
get_repo_path,
init_working_repository,
list_remote_branches,
preflight_remote_repository,
)
from src.utils.git_control import (
checkout_branch,
commit_changes,
create_branch,
delete_branch,
fetch,
get_status,
merge,
pull,
push,
)
from src.utils.git_files import commit_file, get_file_content, list_branches, list_tree
from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url
router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
async def _get_repo(
session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID
) -> GitRepository:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
return repo
async def _get_repo_on_disk(
session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID
) -> GitRepository:
repo = await _get_repo(session, user_id, project_id, repo_id)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
return repo
def _parse_remote_url(remote_url: str | None, force_original: bool) -> str | None:
if not remote_url or force_original:
return remote_url
parse_result = parse_git_url(remote_url)
if parse_result["needs_parsing"] and parse_result["base_url"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "The provided URL appears to be a browser URL, not a git clone URL",
"suggested_url": parse_result["base_url"],
"original_url": remote_url,
"error_code": "URL_NEEDS_PARSING",
},
)
return parse_result.get("base_url") or remote_url
async def _commit_author(session: AsyncSession, user_id: uuid.UUID) -> tuple[str, str]:
user = await _get_user(session, user_id)
return user.name or "Unknown", user.email or "unknown@example.com"
@router.get("/repositories", response_model=list[GitRepositoryResponse])
async def list_user_repositories(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
"""List all repositories owned by the user."""
result = await session.execute(select(GitRepository).where(GitRepository.owner_id == user_id))
return list(result.scalars().all())
@router.post("/repositories/parse-url", response_model=URLParseResponse)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction."""
return URLParseResponse(**parse_git_url(data.url))
@router.post("/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
async def create_external_repository(
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new external git repository (not tied to any project)."""
_user = await _get_user(session, user_id)
existing = await session.execute(
select(GitRepository).where(
GitRepository.project_id.is_(None),
GitRepository.owner_id == user_id,
GitRepository.name == data.name,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
remote_url = _parse_remote_url(data.remote_url, data.force_original_url)
ssh_key_id, ssh_key = None, None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
if remote_url:
preflight_remote_repository(remote_url, ssh_key)
repo = GitRepository(name=data.name, path="", project_id=None, owner_id=user_id, remote_url=remote_url, ssh_key_id=ssh_key_id)
session.add(repo)
await session.flush()
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
repo.path = repo_path
if remote_url:
try:
clone_working_repository(remote_url, repo_path, ssh_key)
repo.is_mirror = False
except Exception as exc:
await session.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
else:
init_working_repository(repo_path)
repo.is_mirror = False
await session.commit()
return repo
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
async def list_repositories(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
return list(result.scalars().all())
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_repository(
project_id: uuid.UUID, repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
repo = await _get_repo(session, user_id, project_id, repo_id)
if os.path.exists(repo.path):
shutil.rmtree(repo.path)
await session.delete(repo)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
async def create_repository(
project_id: uuid.UUID,
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new git repository in a project."""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
existing = await session.execute(
select(GitRepository).where(GitRepository.project_id == project_id, GitRepository.name == data.name)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
remote_url = _parse_remote_url(data.remote_url, data.force_original_url)
ssh_key_id, ssh_key = None, None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
if remote_url:
preflight_remote_repository(remote_url, ssh_key)
repo_path = get_repo_path(user_id, project_id, data.name)
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url:
clone_working_repository(remote_url, repo_path, ssh_key)
else:
init_working_repository(repo_path)
repo = GitRepository(
name=data.name, path=repo_path, project_id=project_id, owner_id=user_id,
is_mirror=False, remote_url=remote_url, ssh_key_id=ssh_key_id,
)
session.add(repo)
await session.commit()
await session.refresh(repo)
return repo
@router.patch("/{project_id}/repositories/{repo_id}/ssh-key", response_model=GitRepositoryResponse)
async def update_repository_ssh_key(
project_id: uuid.UUID, repo_id: uuid.UUID, data: UpdateSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
repo = await _get_repo(session, user_id, project_id, repo_id)
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
repo.ssh_key_id = ssh_key_id
else:
repo.ssh_key_id = None
await session.commit()
await session.refresh(repo)
return repo
@router.get("/{project_id}/repositories/{repo_id}/history")
async def get_repository_history(
project_id: uuid.UUID, repo_id: uuid.UUID,
view: str = "graph", branch: str | None = None, limit: int = 100, offset: int = 0,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
async def get_repository_commit(
project_id: uuid.UUID, repo_id: uuid.UUID, commit_hash: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
return get_commit_detail(repo.path, commit_hash)
except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
async def list_repository_files(
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str = "main", path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileListResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
entries = list_tree(repo.path, branch=branch, path=path)
return FileListResponse(path=path, branch=branch, entries=[
{"name": e.name, "type": e.type, "path": e.path, "size": e.size, "mode": e.mode, "last_commit": e.last_commit}
for e in entries
])
except RuntimeError as e:
logger.error("Failed to list files for repo %s: %s", repo_id, str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
async def get_repository_file_content(
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str, path: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileContentResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
fc = get_file_content(repo.path, branch=branch, path=path)
return FileContentResponse(path=fc.path, branch=fc.branch, content=fc.content, size=fc.size,
encoding=fc.encoding, language=fc.language, is_binary=fc.is_binary, last_commit=fc.last_commit)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse)
async def get_repository_branches(
project_id: uuid.UUID, repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> BranchesResponse:
repo = await _get_repo(session, user_id, project_id, repo_id)
is_valid = os.path.isdir(os.path.join(repo.path, ".git")) or os.path.isfile(os.path.join(repo.path, "HEAD"))
if is_valid:
try:
branches, default_branch = list_branches(repo.path)
return BranchesResponse(branches=[
{"name": b.name, "is_default": b.is_default, "last_commit": b.last_commit} for b in branches
], default_branch=default_branch)
except RuntimeError as e:
logger.error("Failed to list branches for repo %s: %s", repo_id, str(e))
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e
if repo.remote_url:
ssh_key = await session.get(SSHKey, repo.ssh_key_id) if repo.ssh_key_id else None
try:
remote_branches, default_branch = list_remote_branches(repo.remote_url, ssh_key)
if remote_branches:
return BranchesResponse(branches=[
{"name": b, "is_default": b == default_branch, "last_commit": None} for b in remote_branches
], default_branch=default_branch)
except RuntimeError:
pass
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk — re-clone or re-create the repository")
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
async def update_repository_file(
project_id: uuid.UUID, repo_id: uuid.UUID, data: FileUpdateRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileUpdateResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
author_name, author_email = await _commit_author(session, user_id)
try:
commit_hash = commit_file(repo.path, data.branch, data.path, data.content, data.commit_message, author_name, author_email)
return FileUpdateResponse(commit_hash=commit_hash, message=data.commit_message, branch=data.branch)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
async def get_repository_status(
project_id: uuid.UUID, repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> StatusResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
s = get_status(repo.path)
return StatusResponse(branch=s.branch, modified=s.modified, added=s.added, deleted=s.deleted,
untracked=s.untracked, renamed=s.renamed, ahead=s.ahead, behind=s.behind)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/branches")
async def create_repository_branch(
project_id: uuid.UUID, repo_id: uuid.UUID, data: BranchCreateRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
create_branch(repo.path, data.name, data.base_branch)
return {"message": f"Branch '{data.name}' created", "branch": data.name}
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
async def delete_repository_branch(
project_id: uuid.UUID, repo_id: uuid.UUID, branch_name: str, force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
delete_branch(repo.path, branch_name, force)
return {"message": f"Branch '{branch_name}' deleted"}
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/checkout")
async def checkout_repository_branch(
project_id: uuid.UUID, repo_id: uuid.UUID, data: CheckoutRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
checkout_branch(repo.path, data.branch)
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
async def commit_repository_changes(
project_id: uuid.UUID, repo_id: uuid.UUID, data: CommitRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> CommitResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
author_name, author_email = await _commit_author(session, user_id)
try:
commit_hash = commit_changes(repo.path, data.message, author_name, author_email, data.files)
return CommitResponse(commit_hash=commit_hash, message=data.message)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
async def fetch_repository(
project_id: uuid.UUID, repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FetchResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
fetch(repo.path)
return FetchResponse(message="Fetched from remote")
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
async def pull_repository(
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> PullResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
pull(repo.path, branch)
return PullResponse(message="Pulled from remote")
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
async def push_repository(
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> PushResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
push(repo.path, branch)
return PushResponse(message="Pushed to remote")
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
async def merge_repository_branches(
project_id: uuid.UUID, repo_id: uuid.UUID, data: MergeRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> MergeResponse:
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
try:
commit_hash = merge(repo.path, data.source_branch, data.target_branch, data.message)
return MergeResponse(commit_hash=commit_hash, message=data.message or f"Merge {data.source_branch}")
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@@ -3,29 +3,42 @@ import shutil
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import func, select
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import GitRepository
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models import SSHKey
from src.models import ToolInstance
from src.schemas.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/projects", tags=["projects"])
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post(
"",
response_model=ProjectResponse,
@@ -63,77 +76,26 @@ async def create_project(
@router.get(
"",
response_model=list[ProjectResponse],
summary="List all projects",
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
description="Retrieve all projects owned by the authenticated user.",
)
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
) -> list[Project]:
"""List all projects for the authenticated user.
Returns projects with nested repositories and workspaces for inline display.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(
select(Project)
.where(Project.owner_id == user.id)
.order_by(Project.created_at.desc())
)
projects = result.scalars().all()
from src.models import Workspace
enriched = []
for project in projects:
repos_result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project.id)
)
repositories = []
for repo in repos_result.scalars().all():
ws_result = await session.execute(
select(Workspace).where(Workspace.repo_id == repo.id)
)
workspaces = []
for ws in ws_result.scalars().all():
# Count instances
inst_result = await session.execute(
select(func.count()).where(ToolInstance.workspace_id == ws.id)
)
instance_count = inst_result.scalar() or 0
workspaces.append(
{
"id": str(ws.id),
"name": ws.name,
"branch": ws.branch,
"status": ws.status,
"instance_count": instance_count,
}
)
repositories.append(
{
"id": str(repo.id),
"name": repo.name,
"remote_url": repo.remote_url,
"workspaces": workspaces,
}
)
enriched.append(
{
"id": str(project.id),
"name": project.name,
"description": project.description,
"owner_id": str(project.owner_id),
"repositories": repositories,
"created_at": project.created_at.isoformat()
if project.created_at
else None,
}
)
return enriched
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
@router.get(
@@ -222,9 +184,7 @@ async def delete_project(
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project_id)
)
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
repositories = result.scalars().all()
for repo in repositories:
if os.path.exists(repo.path):
@@ -1,24 +1,18 @@
import base64
import uuid
from datetime import datetime
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings
from src.models import SSHKey
from src.schemas.project import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
@@ -27,7 +21,7 @@ def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
# Derive a 32-byte key from the session secret using SHA256
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
@@ -59,6 +53,36 @@ def generate_ssh_key_pair() -> tuple[str, str]:
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
@router.post(
"",
response_model=SSHKeyResponse,
@@ -147,9 +171,7 @@ async def delete_ssh_key(
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
await session.delete(ssh_key)
await session.commit()
@@ -181,9 +203,7 @@ async def sign_payload(
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
@@ -222,9 +242,7 @@ async def verify_signature(
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
-26
View File
@@ -1,26 +0,0 @@
# apps/api/src/api/system (index)
dir: apps/api/src/api/system
## role
Provides system-level API endpoints for monitoring, administration, and operational infrastructure including health checks, dashboards, real-time events, notifications, and container terminal access.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## children
-
## files
- __init__.py
- dashboard.py
- events.py
- health.py
- instance_proxy.py
- notifications.py
- terminal.py
## links
index: apps/api/src/api/system/.pi-map.index.md
map: apps/api/src/api/system/.pi-map.md
## workflows
- change system behavior
read: __init__.py, dashboard.py, events.py
## dirty
-
-33
View File
@@ -1,33 +0,0 @@
# apps/api/src/api/system
dir: apps/api/src/api/system
index: apps/api/src/api/system/.pi-map.index.md
## role
Provides system-level API endpoints for monitoring, administration, and operational infrastructure including health checks, dashboards, real-time events, notifications, and container terminal access.
## files
- __init__.py | Aggregates and re-exports system API routers from submodules for centralized access. | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with counts of projects, repositories, SSH keys, and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project
- events.py | Implements an SSE streaming endpoint that broadcasts instance events to authenticated users with per-user connection limits and backpressure handling. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
- health.py | Provides FastAPI health check endpoints that monitor database connectivity, disk space, and system uptime with performance timing. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
- instance_proxy.py | HTTP proxy router that forwards incoming requests to running containerized tool instances after verifying ownership and instance status. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
- notifications.py | Provides FastAPI REST endpoints for managing user notifications including listing, marking read/unread, dismissing, and clearing all notifications with support for muted categories. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy
- terminal.py | Provides WebSocket and REST endpoints for browser-based terminal access to running Docker container tool instances, supporting multiple named sessions, authentication, and terminal reset/reconnect functionality. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
## arch
FastAPI router composition pattern with modular sub-routers aggregated via __init__.py, combining synchronous REST endpoints, SSE streaming, and WebSocket connections for real-time features, with authentication enforcement and resource ownership validation across operational and infrastructure concerns.
## tags
terminal, session, call:terminal, call:, get, src, response, instance
## symbols
- NotificationItem
- NotificationListResponse
- UnreadCountResponse
- MarkAllReadResponse
- ClearAllResponse
- SessionRef
- get_dashboard_summary
- events_stream
## workflows
- change system behavior
read: __init__.py, dashboard.py, events.py
## dirty
-
-17
View File
@@ -1,17 +0,0 @@
"""System API routers module."""
from src.api.system.dashboard import router as dashboard_router
from src.api.system.events import router as events_router
from src.api.system.health import router as health_router
from src.api.system.instance_proxy import router as instance_proxy_router
from src.api.system.notifications import router as notifications_router
from src.api.system.terminal import router as terminal_router
__all__ = [
"dashboard_router",
"events_router",
"health_router",
"instance_proxy_router",
"notifications_router",
"terminal_router",
]
@@ -12,10 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import TerminalSessionModel
from src.models import ToolInstance
from src.models import ToolType
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -222,7 +222,8 @@ async def _handle_terminal_websocket(
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session, slot_session_id)
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
# Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(
_write_loop(session_ref, websocket, instance_id)
)
@@ -231,7 +232,7 @@ async def _handle_terminal_websocket(
# Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait(
[write_task, heartbeat_task],
[read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED,
)
@@ -266,6 +267,28 @@ async def _handle_terminal_websocket(
)
async def _read_loop(session_ref: SessionRef, websocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
data = await session.read_output()
if data:
try:
await websocket.send_bytes(data)
except WebSocketDisconnect:
break
except Exception:
break
else:
await asyncio.sleep(0.01)
except Exception:
pass
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container."""
try:
@@ -296,10 +319,6 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
rows,
)
await session.resize(cols, rows)
elif msg_type == "ack":
char_count = ctrl.get("chars", 0)
if char_count > 0:
session.acknowledge_data(char_count)
elif msg_type == "reset":
# Reset terminal session (scoped to current slot)
logger.debug(
-26
View File
@@ -1,26 +0,0 @@
# apps/api/src/api/tool (index)
dir: apps/api/src/api/tool
## role
Provides FastAPI REST API endpoints for managing containerized tools throughout their lifecycle, including type definitions, instance management, session tracking, and Docker operations.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## children
-
## files
- __init__.py
- sessions.py
- tool_definitions.py
- tool_instances.py
- tool_lifecycle.py
- tool_types.py
- tool_types_validation.py
## links
index: apps/api/src/api/tool/.pi-map.index.md
map: apps/api/src/api/tool/.pi-map.md
## workflows
- change tool behavior
read: __init__.py, sessions.py, tool_definitions.py
## dirty
-
-33
View File
@@ -1,33 +0,0 @@
# apps/api/src/api/tool
dir: apps/api/src/api/tool
index: apps/api/src/api/tool/.pi-map.index.md
## role
Provides FastAPI REST API endpoints for managing containerized tools throughout their lifecycle, including type definitions, instance management, session tracking, and Docker operations.
## files
- __init__.py | Aggregates and exports router modules for the Tool API package. | dep: src.api.tool.sessions, src.api.tool.tool_definitions, src.api.tool.tool_instances, src.api.tool.tool_lifecycle, src.api.tool.tool_types
- sessions.py | Provides a FastAPI endpoint to retrieve all active tool sessions (running instances) for the currently authenticated user with related metadata. | exp: func:get_user_sessions(user_id, session) → dict, call:_get_user, call:session.execute, call:select(ToolInstance) .where(ToolInstance.owner_id == user_id) .where( ToolInstance.status.in_( ["running", "building", "pending", "stopped", "error"] ) ) .order_by, call:ToolInstance.status.in_, call:ToolInstance.created_at.desc, call:result.scalars().all, call:session.get, call:sessions.append, call:str, call:instance.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
- tool_definitions.py | Provides FastAPI CRUD endpoints for managing tool definition manifests with compilation to Dockerfile and Docker Compose previews. | exp: class:CreateToolDefinitionRequest, class:UpdateToolDefinitionRequest, func:create_tool_definition(data: CreateToolDefinitionRequest, user_id, session) → dict, call:uuid.UUID, call:session.get, call:session.execute, call:select(ToolDefinitionManifest).where, call:existing.scalar_one_or_none, call:ToolDefinitionManifest, call:session.add, call:session.commit, call:session.refresh, call:logger.info, call:str, call:definition.created_at.isoformat, raise:HTTPException, func:list_tool_definitions(user_id, session, include_bases) → dict, call:select, call:query.where, call:ToolDefinitionManifest.is_base.is_, call:session.execute, call:query.order_by, call:ToolDefinitionManifest.created_at.desc, call:result.scalars().all, call:str, call:d.created_at.isoformat, func:get_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:str, call:definition.created_at.isoformat, call:definition.updated_at.isoformat, raise:HTTPException, func:update_tool_definition(definition_id: uuid.UUID, data: UpdateToolDefinitionRequest, user_id, session) → dict, call:session.get, call:session.commit, call:session.refresh, call:logger.info, call:str, call:definition.updated_at.isoformat, raise:HTTPException, func:delete_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:session.execute, call:select(ToolType).where, call:result.scalars().all, call:", ".join, call:session.delete, call:session.commit, call:logger.info, call:str, raise:HTTPException, func:compile_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:dict, call:resolve_base, call:deep_merge, call:compile_dockerfile, call:compile_entrypoint, call:compute_image_tag, call:compile_compose, call:session.commit, call:str, raise:HTTPException | dep: logging, uuid, fastapi, pydantic, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.build.manifest_compiler
- tool_instances.py | FastAPI router providing REST endpoints for managing tool instances (CRUD, logs, health checks, tunnel management, proxying, and event history) within project repositories. | exp: func:create_instance(project_id: uuid.UUID, repo_id: uuid.UUID, data: CreateInstanceRequest, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:create_tool_instance, call:str, call:instance.created_at.isoformat, raise:HTTPException, func:list_instances(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.execute, call:select(ToolInstance) .where(ToolInstance.repository_id == repo_id) .where(ToolInstance.owner_id == user_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat, func:get_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:str, call:instance.created_at.isoformat, call:instance.last_started_at.isoformat, raise:HTTPException, func:rename_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:data.get("display_name", "").strip, call:rename_tool_instance, call:str, raise:HTTPException, func:get_instance_logs(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, tail, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_logs, raise:HTTPException, func:recreate_tunnel_endpoint(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:recreate_instance_tunnel, raise:HTTPException, func:check_instance_tunnel_health(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:instance.probe_result.get, call:"\n".join, call:check_tunnel_health, call:tunnel_health.get, raise:HTTPException, func:get_instance_events(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, limit, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.get, call:session.execute, call:select(InstanceEvent) .where(InstanceEvent.instance_id == instance_id) .order_by(InstanceEvent.created_at.desc()) .limit, call:InstanceEvent.created_at.desc, call:result.scalars().all, call:str, call:row.created_at.isoformat, raise:HTTPException, func:proxy_to_instance(request: Request, project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, path, user_id, session) → Response, call:session.get, call:str, call:dict, call:headers.pop, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:response_headers.pop, call:Response, raise:HTTPException | dep: logging, uuid, httpx, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.docker, src.services.shared.tunnel, src.schemas.tool, src.services.tool.instance_service
- tool_lifecycle.py | FastAPI router providing REST endpoints for managing Docker-based tool instance lifecycle operations (start, stop, restart, delete). | exp: func:start_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:start_tool_instance, raise:HTTPException, func:stop_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:stop_tool_instance, raise:HTTPException, func:restart_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:restart_tool_instance, raise:HTTPException, func:delete_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, force, user_id, session) → None, call:_get_user, call:_get_owned_project, call:delete_tool_instance, call:str, call:detail.lower, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.schemas.tool, src.services.tool.instance_service
- tool_types.py | Defines FastAPI REST endpoints for CRUD operations on tool types with validation for Docker Compose, Dockerfile, and manifest definitions. | exp: func:_require_admin(user: User) → None, func:create_tool_type(data: ToolTypeCreate, user_id, session) → ToolType, call:_get_user, call:_require_admin, call:session.scalar, call:select(ToolType).where, call:ToolType, call:session.add, call:session.commit, call:session.refresh, raise:HTTPException, func:list_tool_types(user_id, session) → list[ToolType], call:_get_user, call:session.execute, call:select(ToolType).order_by, call:list, call:result.scalars().all, func:get_tool_type(tool_type_id: uuid.UUID, user_id, session) → ToolType, call:_get_user, call:session.get, raise:HTTPException, func:update_tool_type(tool_type_id: uuid.UUID, data: ToolTypeUpdate, user_id, session) → ToolType, call:_get_user, call:_require_admin, call:session.get, call:data.model_dump, call:update_data.get, call:validate_compose_yaml, call:check_port_exposed, call:validate_required_variables, call:update_data.items, call:setattr, call:session.commit, call:session.refresh, raise:HTTPException, func:validate_tool_type_template(data: ToolTypeValidateRequest, user_id, session) → dict, call:_get_user, call:errors.append, call:validate_compose_yaml, call:str, call:data.dockerfile_template.strip().startswith, call:len, func:validate_tool_type(tool_type_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:session.get, call:errors.append, call:validate_compose_yaml, call:str, call:tool_type.dockerfile_template.strip().startswith, call:len, raise:HTTPException, func:delete_tool_type(tool_type_id: uuid.UUID, user_id, session) → None, call:_get_user, call:_require_admin, call:session.get, call:session.delete, call:session.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.api.tool.tool_types_validation, src.auth.dependencies, src.models, src.models.user, src.schemas.tool
- tool_types_validation.py | Provides validation utilities for Docker Compose templates including YAML parsing, port exposure checking, and required template variable verification. | exp: func:sanitize_template_vars(template: str) → str, call:re.sub, func:validate_compose_yaml(template: str) → dict, call:sanitize_template_vars, call:yaml.safe_load, call:isinstance, raise:ValueError, func:check_port_exposed(parsed: dict, port: int) → bool, call:str, call:isinstance, call:parsed["services"].values, call:port_mapping.split, call:mapping.split, func:validate_required_variables(template: str, variables: list[str]) → None, raise:HTTPException | dep: re, yaml, fastapi
## arch
Modular FastAPI router pattern with domain-driven separation of concerns (types, instances, sessions, lifecycle) and Docker-centric validation/compilation utilities for container orchestration.
## tags
get, call:, tool, raise:httpexception, user, instance, call:str, call:session.get
## symbols
- CreateToolDefinitionRequest
- UpdateToolDefinitionRequest
- get_user_sessions
- create_tool_definition
- list_tool_definitions
- get_tool_definition
- update_tool_definition
- delete_tool_definition
## workflows
- change tool behavior
read: __init__.py, sessions.py, tool_definitions.py
## dirty
-
-15
View File
@@ -1,15 +0,0 @@
"""Tool API routers module."""
from src.api.tool.sessions import sessions_router
from src.api.tool.tool_definitions import router as tool_definitions_router
from src.api.tool.tool_instances import router as tool_instances_router
from src.api.tool.tool_lifecycle import router as tool_lifecycle_router
from src.api.tool.tool_types import router as tool_types_router
__all__ = [
"sessions_router",
"tool_definitions_router",
"tool_instances_router",
"tool_lifecycle_router",
"tool_types_router",
]
-86
View File
@@ -1,86 +0,0 @@
"""Sessions API endpoints (running instances for current user)."""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import GitRepository
from src.models import Project
from src.models import ToolInstance
from src.models import ToolType
sessions_router = APIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get(
"/me/sessions",
summary="Get user sessions",
description="Get all active sessions (running instances) for the current user.",
)
async def get_user_sessions(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all active sessions for the current user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing list of active sessions with instance details.
"""
_user = await _get_user(session, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.owner_id == user_id)
.where(
ToolInstance.status.in_(
["running", "building", "pending", "stopped", "error"]
)
)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
sessions = []
for instance in instances:
tool_type = await session.get(ToolType, instance.tool_type_id)
repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id)
workspace_name = None
if instance.workspace_id:
from src.models import Workspace as WorkspaceModel
workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace:
workspace_name = workspace.name
sessions.append(
{
"id": str(instance.id),
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"workspace_name": workspace_name,
"status": instance.status,
"url": instance.url,
"selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat()
if instance.created_at
else None,
}
)
return {"sessions": sessions}
-419
View File
@@ -1,419 +0,0 @@
"""Tool instance API endpoints."""
import logging
import uuid
import httpx
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
Response,
status,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import ToolInstance
from src.services.docker import get_container_logs, get_container_status
from src.services.shared.tunnel import check_tunnel_health
from src.schemas.tool import CreateInstanceRequest
from src.services.tool.instance_service import (
create_tool_instance,
recreate_instance_tunnel,
rename_tool_instance,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["tool-instances"])
@router.post(
"/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance",
description="Create a new tool instance for a repository.",
)
async def create_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: CreateInstanceRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
instance = await create_tool_instance(session, user_id, project_id, repo_id, data)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
return {
"id": str(instance.id),
"name": instance.name,
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"created_at": instance.created_at.isoformat(),
}
@router.get(
"/{project_id}/repositories/{repo_id}/instances",
summary="List instances",
description="List all tool instances for a repository.",
)
async def list_instances(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.repository_id == repo_id)
.where(ToolInstance.owner_id == user_id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
return [
{"id": str(i.id), "name": i.name, "display_name": i.display_name,
"tool_type_id": str(i.tool_type_id), "status": i.status, "url": i.url,
"port": i.port, "container_id": i.container_id,
"created_at": i.created_at.isoformat() if i.created_at else None}
for i in instances
]
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Get instance",
description="Get details for a specific tool instance.",
)
async def get_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
docker_status = None
if instance.container_id:
docker_status = get_container_status(instance.container_id)
return {
"id": str(instance.id),
"name": instance.name,
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"url": instance.url,
"public_url": instance.public_url,
"port": instance.port,
"container_id": instance.container_id,
"container_name": instance.container_name,
"compose_path": instance.compose_path,
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"ssh_key_ids": instance.ssh_key_ids,
"created_at": instance.created_at.isoformat() if instance.created_at else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"docker_status": docker_status,
}
@router.patch(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Rename instance",
description="Update the display name of a tool instance.",
)
async def rename_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
display_name = data.get("display_name", "").strip()
if not display_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="display_name is required",
)
try:
instance = await rename_tool_instance(
session, user_id, project_id, repo_id, instance_id, display_name
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
)
return {
"id": str(instance.id),
"name": instance.name,
"display_name": instance.display_name,
}
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
summary="Get instance logs",
description="Get container logs for a tool instance.",
)
async def get_instance_logs(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
tail: int = 100,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
if not instance.container_id:
return {"logs": "No container running"}
logs = get_container_logs(instance.container_id, tail)
return {"logs": logs}
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel",
summary="Recreate tunnel",
description="Recreate the temporary Cloudflare tunnel for a running instance.",
)
async def recreate_tunnel_endpoint(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await recreate_instance_tunnel(
session, user_id, project_id, repo_id, instance_id
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
summary="Check instance health",
description="Check container and tunnel health for an instance.",
)
async def check_instance_tunnel_health(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
container_info = {"status": "not_found", "exit_code": None, "health": None}
if instance.container_id:
container_info = get_container_status(instance.container_id)
response = {
"healthy": False,
"container_status": container_info["status"],
"container_health": container_info["health"],
"tunnel_status": "not_applicable",
"tunnel_status_code": None,
"probe_status": "not_applicable",
"last_probe_output": None,
"error": None,
}
if instance.status == "probing":
response["probe_status"] = "pending"
elif instance.probe_result:
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
if instance.url and instance.status in ("running", "unhealthy"):
tunnel_health = check_tunnel_health(instance.url)
response["tunnel_status"] = tunnel_health["tunnel_status"]
response["tunnel_status_code"] = tunnel_health.get("status_code")
if tunnel_health.get("error"):
response["error"] = tunnel_health["error"]
container_healthy = container_info["status"] == "running"
if instance.url:
response["healthy"] = container_healthy and response["tunnel_status"] == "healthy"
else:
response["healthy"] = container_healthy
if not container_healthy:
response["error"] = f"Container is {container_info['status']}"
if container_info["exit_code"] is not None:
response["error"] += f" (exit code: {container_info['exit_code']})"
return response
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/events",
summary="Get instance events history",
description="Get lifecycle event history for a tool instance.",
)
async def get_instance_events(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
limit: int = 50,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
from sqlalchemy import select
from src.models import InstanceEvent
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
result = await session.execute(
select(InstanceEvent)
.where(InstanceEvent.instance_id == instance_id)
.order_by(InstanceEvent.created_at.desc())
.limit(limit)
)
rows = result.scalars().all()
return [
{
"id": str(row.id),
"event_type": row.event_type,
"status": row.status,
"message": row.message,
"metadata": row.event_metadata,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in rows
]
@router.api_route(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
)
async def proxy_to_instance(
request: Request,
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
if instance.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not authorized to access this instance",
)
if instance.status != "running" or not instance.container_name:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="instance is not running",
)
target_url = f"http://{instance.container_name}:{instance.port}"
if path:
target_url += f"/{path}"
query_string = str(request.query_params)
if query_string:
target_url += f"?{query_string}"
headers = dict(request.headers)
headers.pop("host", None)
headers.pop("cookie", None)
try:
async with httpx.AsyncClient() as client:
body = await request.body()
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
follow_redirects=False,
timeout=30.0,
)
except Exception as exc:
logger.error("Proxy error: %s", exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"failed to reach instance: {exc}",
)
response_headers = dict(response.headers)
for header in ["content-encoding", "transfer-encoding", "connection"]:
response_headers.pop(header, None)
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
)
-135
View File
@@ -1,135 +0,0 @@
"""Tool instance lifecycle API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.schemas.tool import StartInstanceRequest
from src.services.tool.instance_service import (
delete_tool_instance,
restart_tool_instance,
start_tool_instance,
stop_tool_instance,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["tool-instances"])
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
summary="Start instance",
description="Start a tool instance using Docker Compose.",
)
async def start_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
data: StartInstanceRequest | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await start_tool_instance(
session, user_id, project_id, repo_id, instance_id, data
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
summary="Stop instance",
description="Stop a running tool instance.",
)
async def stop_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await stop_tool_instance(session, user_id, project_id, repo_id, instance_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
summary="Restart instance",
description="Restart a tool instance.",
)
async def restart_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
return await restart_tool_instance(
session, user_id, project_id, repo_id, instance_id
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
@router.delete(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Delete instance",
description="Delete a tool instance and remove its Docker containers and files.",
)
async def delete_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
try:
await delete_tool_instance(
session, user_id, project_id, repo_id, instance_id, force
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except RuntimeError as exc:
detail = str(exc)
if "uncommitted changes" in detail.lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Repository has uncommitted changes",
"changed_files": detail,
"force_required": True,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
)
@@ -9,9 +9,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ToolDefinitionManifest
from src.models import ToolType
from src.services.build.manifest_compiler import (
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_type import ToolType
from src.services.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
@@ -173,7 +173,7 @@ async def list_tool_definitions(
"""
query = select(ToolDefinitionManifest)
if not include_bases:
query = query.where(ToolDefinitionManifest.is_base.is_(False))
query = query.where(ToolDefinitionManifest.is_base == False)
result = await session.execute(
query.order_by(ToolDefinitionManifest.created_at.desc())
File diff suppressed because it is too large Load Diff
@@ -1,23 +1,19 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool.tool_types_validation import (
from src.api.tool_types_validation import (
check_port_exposed,
validate_compose_yaml,
validate_required_variables,
)
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import ToolType
from src.models.tool_type import ToolType
from src.models.user import User
from src.schemas.tool import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
@@ -33,6 +29,237 @@ async def _require_admin(user: User) -> None:
pass
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int = 0
definition_type: str = "compose"
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None or not v.strip():
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None or not v.strip():
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str) -> str:
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
data = info.data
requires_port = data.get("requires_port", True)
if not requires_port:
return v
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(
f"Required variable '{var}' not found in compose template"
)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
# Validate that default_port is exposed in compose template (only if requires_port)
if (
self.requires_port
and self.definition_type == "compose"
and self.compose_template
):
try:
parsed = validate_compose_yaml(self.compose_template)
except ValueError:
return self
if not check_port_exposed(parsed, self.default_port):
raise ValueError(
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
)
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interface_type: str
requires_port: bool
default_port: int
definition_type: str
manifest_id: uuid.UUID | None
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.post(
"",
response_model=ToolTypeResponse,
@@ -234,6 +461,12 @@ async def update_tool_type(
return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post(
"/validate",
summary="Validate tool type template",
@@ -60,14 +60,8 @@ def check_port_exposed(parsed: dict, port: int) -> bool:
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Port mappings can be "host:container", "ip:host:container",
# "container", or ".../protocol". The container port is the
# last numeric segment before any protocol suffix.
mapping = port_mapping.split("/")[0]
parts = mapping.split(":")
if parts[-1] == port_str:
return True
if isinstance(port_mapping, str) and port_str in port_mapping:
return True
elif isinstance(port_mapping, int) and port_mapping == port:
return True
-23
View File
@@ -1,23 +0,0 @@
# apps/api/src/api/user (index)
dir: apps/api/src/api/user
## role
Provides user-facing API endpoints for authentication, profile management, and SSH key operations in the FastAPI backend.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## children
-
## files
- __init__.py
- auth.py
- ssh_keys.py
- users.py
## links
index: apps/api/src/api/user/.pi-map.index.md
map: apps/api/src/api/user/.pi-map.md
## workflows
- change user behavior
read: __init__.py, auth.py, ssh_keys.py
## dirty
-
-30
View File
@@ -1,30 +0,0 @@
# apps/api/src/api/user
dir: apps/api/src/api/user
index: apps/api/src/api/user/.pi-map.index.md
## role
Provides user-facing API endpoints for authentication, profile management, and SSH key operations in the FastAPI backend.
## files
- __init__.py | Aggregates and exports user-related API routers from submodules for easy importing. | dep: src.api.user.auth, src.api.user.ssh_keys, src.api.user.users
- auth.py | Implements OAuth2/OIDC authentication endpoints for a FastAPI application using Authentik as the identity provider, including login initiation, callback handling, session management, logout, and current user retrieval. | exp: func:get_db_session() → AsyncGenerator[AsyncSession, None], call:SessionLocal, func:login(next) → RedirectResponse, call:Settings, call:token_urlsafe, call:build_login_redirect_url, call:logger.debug, call:RedirectResponse, call:response.set_cookie, func:callback(code: str, state: str, auth_state, auth_next, session) → RedirectResponse, call:logger.debug, call:logger.warning, call:Settings, call:httpx.AsyncClient, call:exchange_code_for_tokens, call:logger.info, call:logger.error, call:fetch_user_info, call:str, call:user_info.get, call:session.scalar, call:select(User).where, call:User, call:session.add, call:session.commit, call:session.refresh, call:create_session_cookie, call:build_cookie_options, call:cast, call:bool, call:cookie_options.get, call:RedirectResponse, call:redirect_response.set_cookie, call:redirect_response.delete_cookie, raise:HTTPException, func:logout(response: Response) → dict[str, str], call:Settings, call:build_cookie_options, call:cast, call:bool, call:str, call:cookie_options.get, call:response.delete_cookie, func:me(session_cookie, session) → dict[str, Any], call:logger.debug, call:bool, call:logger.warning, call:Settings, call:decode_session_cookie, call:session.get, call:logger.info, call:str, raise:HTTPException | dep: logging, secrets, typing, httpx, fastapi, fastapi.responses, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.cookies, src.auth.oidc, src.auth.session, src.config, src.database, src.models.user
- ssh_keys.py | FastAPI router for managing Ed25519 SSH keys including generation, storage with encrypted private keys, signing payloads, and signature verification | exp: func:_get_fernet() → Fernet, call:Settings, call:hashlib.sha256(settings.session_secret.encode()).digest, call:settings.session_secret.encode, call:base64.urlsafe_b64encode, call:Fernet, func:generate_ssh_key_pair() → tuple[str, str], call:Ed25519PrivateKey.generate, call:private_key.public_key, call:private_key.private_bytes, call:serialization.NoEncryption, call:public_key.public_bytes, call:private_bytes.decode, call:public_bytes.decode, func:create_ssh_key(data: SSHKeyCreate, user_id, session) → SSHKey, call:_get_user, call:generate_ssh_key_pair, call:_get_fernet, call:fernet.encrypt(private_key.encode()).decode, call:private_key.encode, call:SSHKey, call:session.add, call:session.commit, call:session.refresh, func:list_ssh_keys(user_id, session) → list[SSHKey], call:_get_user, call:session.execute, call:select(SSHKey).where, call:list, call:result.scalars().all, func:delete_ssh_key(key_id: uuid.UUID, user_id, session) → None, call:_get_user, call:session.get, call:session.delete, call:session.commit, raise:HTTPException, func:sign_payload(key_id: uuid.UUID, data: SignPayloadRequest, user_id, session) → SignatureResponse, call:_get_user, call:session.get, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:serialization.load_ssh_private_key, call:private_key_pem.encode, call:private_key.sign, call:data.payload.encode, call:SignatureResponse, call:base64.b64encode(signature).decode, raise:HTTPException, func:verify_signature(key_id: uuid.UUID, data: VerifySignatureRequest, user_id, session) → VerifySignatureResponse, call:_get_user, call:session.get, call:serialization.load_ssh_public_key, call:ssh_key.public_key.encode, call:base64.b64decode, call:public_key.verify, call:data.payload.encode, call:VerifySignatureResponse, raise:HTTPException | dep: base64, uuid, cryptography.fernet, cryptography.hazmat.primitives, cryptography.hazmat.primitives.asymmetric.ed25519, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.config, src.models, src.schemas.project, hashlib, cryptography
- users.py | Implements FastAPI endpoints for user profile management including retrieval, updates, and avatar uploads with file validation. | exp: func:get_profile(user_id, session) → User, call:_get_user, func:update_profile(data: UserProfileUpdate, user_id, session) → User, call:_get_user, call:len, call:data.name.strip, call:data.email.strip, call:session.commit, call:session.refresh, raise:HTTPException, func:upload_avatar(file: UploadFile, user_id, session) → User, call:_get_user, call:file.read, call:len, call:Path, call:old_path.exists, call:old_path.unlink, call:filename_part.split(".")[-1].lower, call:uuid.uuid4, call:file_path.write_bytes, call:session.commit, call:session.refresh, raise:HTTPException | dep: uuid, pathlib, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.schemas.user, sqlalchemy
## arch
Modular FastAPI router pattern with separate submodules for distinct concerns (auth, profile, SSH keys), using OAuth2/OIDC external identity provider (Authentik), encrypted key storage, and file upload handling.
## tags
user, get, call:, key, ssh, src, cookie, raise:httpexception
## symbols
- get_db_session
- login
- callback
- logout
- me
- _get_fernet
- generate_ssh_key_pair
- create_ssh_key
## workflows
- change user behavior
read: __init__.py, auth.py, ssh_keys.py
## dirty
-
-7
View File
@@ -1,7 +0,0 @@
"""User API routers module."""
from src.api.user.auth import router as auth_router
from src.api.user.ssh_keys import router as ssh_keys_router
from src.api.user.users import router as users_router
__all__ = ["auth_router", "ssh_keys_router", "users_router"]
@@ -2,12 +2,12 @@ import logging
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import UserConfig
from src.schemas.user import UserConfigResponse, UserConfigUpdate
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
@@ -38,6 +38,28 @@ async def _get_or_create_config(
return config
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
@router.get(
"/config",
response_model=UserConfigResponse,
@@ -2,11 +2,11 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User
from src.schemas.user import UserProfileResponse, UserProfileUpdate
router = APIRouter(prefix="/users", tags=["users"])
@@ -16,6 +16,20 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get(
"/me",
response_model=UserProfileResponse,
@@ -63,16 +77,12 @@ async def update_profile(
if data.name is not None:
if len(data.name.strip()) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
user.name = data.name.strip()
if data.email is not None:
if "@" not in data.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
user.email = data.email.strip()
await session.commit()
@@ -1,24 +0,0 @@
# apps/api/src/api/workspace (index)
dir: apps/api/src/api/workspace
## role
Provides REST API endpoints for managing user workspaces, including file operations, Git version control, tool instances, and workspace lifecycle management.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
## children
-
## files
- __init__.py
- workspace_files.py
- workspace_git.py
- workspace_instances.py
- workspaces.py
## links
index: apps/api/src/api/workspace/.pi-map.index.md
map: apps/api/src/api/workspace/.pi-map.md
## workflows
- change workspace behavior
read: __init__.py, workspace_files.py, workspace_git.py
## dirty
-
-31
View File
@@ -1,31 +0,0 @@
# apps/api/src/api/workspace
dir: apps/api/src/api/workspace
index: apps/api/src/api/workspace/.pi-map.index.md
## role
Provides REST API endpoints for managing user workspaces, including file operations, Git version control, tool instances, and workspace lifecycle management.
## files
- __init__.py | Aggregates and re-exports workspace-related API routers from submodules for centralized access. | dep: src.api.workspace.workspace_files, src.api.workspace.workspace_git, src.api.workspace.workspace_instances, src.api.workspace.workspaces
- workspace_files.py | Provides FastAPI endpoints for listing, reading, and writing files within user-owned workspaces with optional Git commit integration. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:list_files(workspace_id: uuid.UUID, path, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.list_directory, raise:HTTPException, func:get_file_content(workspace_id: uuid.UUID, path: str, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.read_file, raise:HTTPException, func:write_file(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:FileService, call:data.get("path", "").strip, call:data.get("message", "").strip, call:service.write_file, call:GitOperations, call:git.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.shared.file_service, sqlalchemy, src.services.git.git_operations
- workspace_git.py | Provides FastAPI REST endpoints for executing git operations (status, branches, commit, push, pull, fetch, checkout, history) on user workspaces with authentication and database persistence. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:git_status(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.status, raise:HTTPException, func:git_branches(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.branches, raise:HTTPException, func:git_commit(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("message", "").strip, call:GitOperations, call:git.commit, raise:HTTPException, func:git_push(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.push, raise:HTTPException, func:git_pull(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.pull, raise:HTTPException, func:git_fetch(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.fetch, raise:HTTPException, func:git_checkout(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("branch", "").strip, call:GitOperations, call:git.checkout, call:session.commit, raise:HTTPException, func:git_history(workspace_id: uuid.UUID, path, limit, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.history, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.git.git_operations, sqlalchemy
- workspace_instances.py | Defines FastAPI endpoints for creating and listing tool instances associated with a specific workspace. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:create_workspace_instance(workspace_id: uuid.UUID, data: CreateWorkspaceInstanceRequest, user_id, session) → dict, call:_get_workspace, call:session.get, call:CreateInstanceRequest, call:str, call:create_tool_instance, call:instance.created_at.isoformat, raise:HTTPException, func:list_workspace_instances(workspace_id: uuid.UUID, user_id, session) → list[dict], call:_get_workspace, call:session.execute, call:select(ToolInstance) .where(ToolInstance.workspace_id == workspace_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.tool, src.services.tool.instance_service
- workspaces.py | FastAPI router providing CRUD endpoints for managing Git workspaces with repository integration, instance tracking, and sync capabilities. | exp: func:list_all_workspaces(user_id, session) → list[dict], call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, GitRepository, instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) .options(selectinload(GitRepository.project)) .where(Workspace.user_id == user_id) .order_by, call:instance_count.label, call:selectinload, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:delete_workspace_top_level(workspace_id: uuid.UUID, force, user_id, session) → dict, call:session.get, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:create_workspace_top_level(data: dict, user_id, session) → dict, call:data.get("repo_id", "").strip, call:uuid.UUID, call:session.get, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:list_workspaces(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_repo, call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, instance_count.label("instance_count"), ) .where(Workspace.repo_id == repo_id) .order_by, call:instance_count.label, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:create_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:get_workspace_detail(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:session.execute, call:select(func.count(ToolInstance.id)).where, call:func.count, call:result.scalar, call:str, call:workspace.last_sync_at.isoformat, call:workspace.created_at.isoformat, call:workspace.updated_at.isoformat, func:update_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:data.get("name", "").strip, call:data.get("branch", "").strip, call:session.commit, call:session.rollback, call:logger.error, call:str, raise:HTTPException, func:delete_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, force, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:sync_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.sync, call:session.commit, call:workspace.last_sync_at.isoformat, raise:HTTPException, func:_get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) → GitRepository, call:session.execute, call:select(GitRepository) .where( GitRepository.id == repo_id, GitRepository.project_id == project_id, ) .options, call:selectinload, call:result.scalar_one_or_none, raise:HTTPException, func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, repo_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.services.shared.workspace_manager
## arch
Modular FastAPI router decomposition with domain-driven vertical slicing (files/git/instances/workspaces as separate modules), aggregated through __init__.py barrel exports, combining database persistence with external Git repository integration.
## tags
workspace, get, raise:httpexception, call:, at.isoformat, git, call:select, call:data.get
## symbols
- _get_workspace
- list_files
- get_file_content
- write_file
- git_status
- git_branches
- git_commit
- git_push
## workflows
- change workspace behavior
read: __init__.py, workspace_files.py, workspace_git.py
## dirty
-
-17
View File
@@ -1,17 +0,0 @@
"""Workspace API routers module."""
from src.api.workspace.workspace_files import router as workspace_files_router
from src.api.workspace.workspace_git import router as workspace_git_router
from src.api.workspace.workspace_instances import router as workspace_instances_router
from src.api.workspace.workspaces import (
all_workspaces_router,
router as workspaces_router,
)
__all__ = [
"all_workspaces_router",
"workspace_files_router",
"workspace_git_router",
"workspace_instances_router",
"workspaces_router",
]
@@ -1,114 +0,0 @@
"""Workspace file API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import Workspace
from src.services.shared.file_service import FileService
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_files(
workspace_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List files in a workspace directory."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
entries = service.list_directory(workspace, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"entries": [
{
"name": e.name,
"path": e.path,
"type": e.type,
"size": e.size,
}
for e in entries
],
}
@router.get("/content")
async def get_file_content(
workspace_id: uuid.UUID,
path: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the content of a text file."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
content = service.read_file(workspace, path)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"content": content, "path": path}
@router.post("/content")
async def write_file(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Write a file and optionally commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
file_path = data.get("path", "").strip()
content = data.get("content", "")
commit_message = data.get("message", "").strip()
if not file_path:
raise HTTPException(status_code=400, detail="File path is required")
try:
service.write_file(workspace, file_path, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if commit_message:
from src.services.git.git_operations import GitOperations
git = GitOperations(workspace)
try:
await git.commit(commit_message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "saved", "path": file_path}
-203
View File
@@ -1,203 +0,0 @@
"""Workspace git API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import Workspace
from src.services.git.git_operations import GitOperations
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/status")
async def git_status(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get git status for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
status = await git.status()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branch": status.branch,
"modified": status.modified,
"added": status.added,
"deleted": status.deleted,
"untracked": status.untracked,
"ahead": status.ahead,
"behind": status.behind,
}
@router.get("/branches")
async def git_branches(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List branches for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
branches, current = await git.branches()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branches": branches,
"current_branch": current,
}
@router.post("/commit")
async def git_commit(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Stage all changes and commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
message = data.get("message", "").strip()
if not message:
raise HTTPException(status_code=400, detail="Commit message is required")
git = GitOperations(workspace)
try:
await git.commit(message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "committed"}
@router.post("/push")
async def git_push(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Push current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.push()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pushed"}
@router.post("/pull")
async def git_pull(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Pull current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.pull()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pulled"}
@router.post("/fetch")
async def git_fetch(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Fetch from origin."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.fetch()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "fetched"}
@router.post("/checkout")
async def git_checkout(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Checkout a branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
branch = data.get("branch", "").strip()
if not branch:
raise HTTPException(status_code=400, detail="Branch name is required")
git = GitOperations(workspace)
try:
await git.checkout(branch)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
workspace.branch = branch
await session.commit()
return {"status": "checked_out", "branch": branch}
@router.get("/history")
async def git_history(
workspace_id: uuid.UUID,
path: str | None = None,
limit: int = 50,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get commit history."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
commits = await git.history(path, limit)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"commits": [
{
"hash": c.hash,
"message": c.message,
"author": c.author,
"date": c.date,
}
for c in commits
],
}
@@ -1,120 +0,0 @@
"""Workspace instance API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import GitRepository
from src.models import ToolInstance
from src.models import Workspace
from src.schemas.tool import CreateInstanceRequest, CreateWorkspaceInstanceRequest
from src.services.tool.instance_service import create_tool_instance
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.post(
"/",
summary="Create instance from workspace",
description="Create a new tool instance mounted on this workspace.",
status_code=status.HTTP_201_CREATED,
)
async def create_workspace_instance(
workspace_id: uuid.UUID,
data: CreateWorkspaceInstanceRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a tool instance directly on a workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
repo = await session.get(GitRepository, workspace.repo_id)
if repo is None:
raise HTTPException(status_code=404, detail="Repository not found")
if repo.project_id is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Repository is not associated with a project",
)
request = CreateInstanceRequest(
tool_type_id=data.tool_type_id,
display_name=data.display_name,
workspace_id=str(workspace.id),
config_profile_id=data.config_profile_id,
ssh_key_ids=data.ssh_key_ids,
)
try:
instance = await create_tool_instance(
session, user_id, repo.project_id, repo.id, request
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)
return {
"id": str(instance.id),
"name": instance.name,
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
"selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat(),
}
@router.get("/")
async def list_workspace_instances(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List tool instances using this workspace."""
await _get_workspace(session, workspace_id, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
return [
{
"id": str(i.id),
"name": i.name,
"display_name": i.display_name,
"status": i.status,
"tool_type_id": str(i.tool_type_id),
"url": i.url,
"port": i.port,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in instances
]

Some files were not shown because too many files have changed in this diff Show More