feat: implement docker infrastructure (US-001)

- Add docker-compose.yml with postgres, redis, api, and web services
- Add multi-stage Dockerfile for API (Python 3.11)
- Add multi-stage Dockerfile for web (Node.js 20 + nginx)
- Add Makefile with common development commands
- Add .env.example with all required environment variables
- Add placeholder pyproject.toml and package.json for builds
- Configure health checks for all services
- Setup persistent volumes for postgres, redis, and repos
- Run services as non-root users
This commit is contained in:
2026-05-16 17:44:39 +00:00
parent 212d072417
commit e7819bfc82
246 changed files with 3625 additions and 17311 deletions
-73
View File
@@ -1,73 +0,0 @@
# Dolt database (managed by Dolt, not git)
dolt/
embeddeddolt/
# Runtime files
bd.sock
bd.sock.startlock
sync-state.json
last-touched
.exclusive-lock
# Daemon runtime (lock, log, pid)
daemon.*
# Interactions log (runtime, not versioned)
interactions.jsonl
# Push state (runtime, per-machine)
push-state.json
# Lock files (various runtime locks)
*.lock
# Credential key (encryption key for federation peer auth — never commit)
.beads-credential-key
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Sync state (local-only, per-machine)
# These files are machine-specific and should not be shared across clones
.sync.lock
export-state/
export-state.json
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
ephemeral.sqlite3
ephemeral.sqlite3-journal
ephemeral.sqlite3-wal
ephemeral.sqlite3-shm
# Dolt server management (auto-started by bd)
dolt-server.pid
dolt-server.log
dolt-server.lock
dolt-server.port
dolt-server.activity
# Corrupt backup directories (created by bd doctor --fix recovery)
*.corrupt.backup/
# Backup data (auto-exported JSONL, local-only)
backup/
# Per-project environment file (Dolt connection config, GH#2520)
.env
# Legacy files (from pre-Dolt versions)
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
db.sqlite
bd.db
# NOTE: Do NOT add negation patterns here.
# They would override fork protection in .git/info/exclude.
# Config files (metadata.json, config.yaml) are tracked by git by default
# since no pattern above ignores them.
-81
View File
@@ -1,81 +0,0 @@
# Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
## What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
## Quick Start
### Essential Commands
```bash
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --claim
bd update <issue-id> --status done
# Sync with Dolt remote
bd dolt push
```
### Working with Issues
Issues in Beads are:
- **Git-native**: Stored in Dolt database with version control and branching
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
- **Branch-aware**: Issues can follow your branch workflow
- **Always in sync**: Auto-syncs with your commits
## Why Beads?
**AI-Native Design**
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 **Developer Focused**
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 **Git Integration**
- Automatic sync with git commits
- Branch-aware issue tracking
- Dolt-native three-way merge resolution
## Get Started with Beads
Try Beads in your own projects:
```bash
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"
```
## Learn More
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
- **Quick Start Guide**: Run `bd quickstart`
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
---
*Beads: Issue tracking that moves at the speed of thought*
-22
View File
@@ -1,22 +0,0 @@
{"id":"phase1-epic","type":"epic","title":"Phase 1: Foundation (Auth, Git Repos, Project Structure)","description":"Rebuild Headquarter platform from scratch starting with foundational layer: Authentik OAuth auth, git repository management with bare repos on disk, SSH key management, user profiles, and basic project structure. Provides core infrastructure for Phase 2 (tool spawning) and Phase 3 (collaboration).","status":"open","priority":0,"parent":null,"external_ref":"prd:./tasks/prd-phase1-foundation.md","dependencies":[]}
{"id":"phase1-001","type":"story","title":"US-001: Docker Infrastructure & Project Bootstrap","description":"As a developer, I want a Docker Compose setup with all required services so that I can run the platform locally.\n\n## Acceptance Criteria\n- [ ] `docker-compose.yml` with PostgreSQL, Redis, Traefik, and Authentik services\n- [ ] `apps/api/Dockerfile` with multi-stage build for FastAPI\n- [ ] `apps/web/Dockerfile` for React SPA with Vite\n- [ ] `.env.example` documenting all required environment variables\n- [ ] Health checks for all services\n- [ ] `Makefile` with common commands (`make up`, `make down`, `make logs`, `make migrate`)\n- [ ] Git repo storage directory mounted as Docker volume\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass","status":"open","priority":1,"parent":"phase1-epic","external_ref":null,"dependencies":[]}
{"id":"phase1-002","type":"story","title":"US-002: Database Models & Alembic Migrations","description":"As a developer, I want database models for users, projects, and git repos so that the data layer is consistent and versioned.\n\n## Acceptance Criteria\n- [ ] SQLAlchemy models: User, Project, GitRepository, SSHKey, UserConfig\n- [ ] Relationships: User owns Projects, Project has GitRepositories, User has SSHKeys\n- [ ] Alembic initialized with initial migration script\n- [ ] Migration command documented in Makefile\n- [ ] Database seed script for development (creates test user)\n- [ ] All models use SQLAlchemy 2.0 async style\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass","status":"open","priority":1,"parent":"phase1-epic","external_ref":null,"dependencies":[]}
{"id":"phase1-003","type":"story","title":"US-003: Authentik OAuth Authentication with httpOnly Cookies","description":"As a user, I want to log in via Authentik OAuth with secure httpOnly cookies so that my session is protected from XSS attacks.\n\n## Acceptance Criteria\n- [ ] OAuth2/OIDC flow implemented with Authentik as provider\n- [ ] Callback endpoint exchanges code for tokens and sets httpOnly, Secure, SameSite=strict cookies\n- [ ] Token refresh mechanism with refresh token rotation\n- [ ] `/auth/me` endpoint returning current authenticated user\n- [ ] `/auth/logout` endpoint clearing all auth cookies\n- [ ] JWT validation middleware protecting all API routes (except public)\n- [ ] Frontend login button redirecting to Authentik\n- [ ] Frontend handles OAuth callback and refreshes user state\n- [ ] Tests mocking Authentik OIDC endpoints\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass","status":"open","priority":1,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-002"]}
{"id":"phase1-004","type":"story","title":"US-004: User Profile Management","description":"As a user, I want to view and edit my profile so that my information is up to date.\n\n## Acceptance Criteria\n- [ ] `GET /users/me` endpoint returning current user profile\n- [ ] `PATCH /users/me` endpoint for updating profile fields (name, email)\n- [ ] Frontend profile page displaying user info\n- [ ] Frontend profile edit form with validation\n- [ ] Avatar upload support (stored locally, not external service)\n- [ ] Tests for profile CRUD operations\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass\n- `npm run typecheck` must pass\n- `npm run lint` must pass","status":"open","priority":2,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-003"]}
{"id":"phase1-005","type":"story","title":"US-005: Git Repository Creation & Cloning","description":"As a user, I want to create new git repositories or clone existing ones so that I can start working on projects.\n\n## Acceptance Criteria\n- [ ] `POST /repos` endpoint to create a new bare git repository\n- [ ] `POST /repos/clone` endpoint to clone an existing repo (bare mirror)\n- [ ] Repositories stored as bare repos on disk in structured path (`/data/repos/{user_id}/{project_id}/{repo_name}.git`)\n- [ ] `GET /repos` listing all user repositories\n- [ ] `GET /repos/{id}` returning repo details (name, path, created_at, last_push)\n- [ ] `DELETE /repos/{id}` removing repo from disk and database\n- [ ] Validation preventing duplicate repo names per project\n- [ ] Frontend repo list page\n- [ ] Frontend forms for create and clone operations\n- [ ] Tests for repo lifecycle (create, clone, delete)\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass\n- `npm run typecheck` must pass\n- `npm run lint` must pass","status":"open","priority":2,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-002"]}
{"id":"phase1-006","type":"story","title":"US-006: SSH Key Management","description":"As a user, I want to generate and manage SSH keys for git operations so that I can authenticate with external git providers.\n\n## Acceptance Criteria\n- [ ] `POST /ssh-keys` endpoint generating Ed25519 key pair\n- [ ] `GET /ssh-keys` listing user's SSH keys\n- [ ] `DELETE /ssh-keys/{id}` removing a key\n- [ ] Public key displayed in OpenSSH format\n- [ ] Private key encrypted at rest (using Fernet with app secret)\n- [ ] SSH keys associated with User (global) or Project (project-specific)\n- [ ] Frontend SSH key management page\n- [ ] Frontend key generation with one-click copy of public key\n- [ ] Tests for key generation and encryption\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass\n- `npm run typecheck` must pass\n- `npm run lint` must pass","status":"open","priority":2,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-002"]}
{"id":"phase1-007","type":"story","title":"US-007: Project Structure & Organization","description":"As a user, I want to organize repositories into projects so that I can group related work.\n\n## Acceptance Criteria\n- [ ] `POST /projects` endpoint creating a new project\n- [ ] `GET /projects` listing user's projects\n- [ ] `GET /projects/{id}` returning project with associated repos\n- [ ] `PATCH /projects/{id}` updating project name/description\n- [ ] `DELETE /projects/{id}` deleting project and all associated repos\n- [ ] Project settings page for default SSH key selection\n- [ ] Frontend project list and detail pages\n- [ ] Frontend project creation form\n- [ ] Tests for project CRUD and cascading delete\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass\n- `npm run typecheck` must pass\n- `npm run lint` must pass","status":"open","priority":2,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-002","phase1-005"]}
{"id":"phase1-008","type":"story","title":"US-008: User Configuration System","description":"As a user, I want to set global preferences so that the platform behaves according to my preferences.\n\n## Acceptance Criteria\n- [ ] `UserConfig` model with key-value storage (JSONB)\n- [ ] `GET /config` endpoint returning user configuration\n- [ ] `PATCH /config` endpoint updating configuration\n- [ ] Supported config keys: default_editor, theme, git_user_name, git_user_email\n- [ ] Frontend settings page with config form\n- [ ] Config applied on frontend (theme, default values)\n- [ ] Tests for config CRUD\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass\n- `npm run typecheck` must pass\n- `npm run lint` must pass","status":"open","priority":2,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-003"]}
{"id":"phase1-009","type":"story","title":"US-009: Frontend Foundation & Layout","description":"As a user, I want a modern React frontend with navigation so that I can access all features.\n\n## Acceptance Criteria\n- [ ] React 18+ with TypeScript and Vite\n- [ ] React Router setup with protected routes\n- [ ] Tailwind CSS for styling\n- [ ] Layout component with header, sidebar navigation, and main content area\n- [ ] Navigation links: Dashboard, Projects, Repositories, SSH Keys, Settings\n- [ ] Responsive design (mobile hamburger menu)\n- [ ] Loading states and error boundaries\n- [ ] Axios/fetch configured with credentials (cookies)\n- [ ] Basic dashboard page showing user stats (repo count, project count)\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass\n- `npm run typecheck` must pass\n- `npm run lint` must pass","status":"open","priority":2,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-003","phase1-004"]}
{"id":"phase1-010","type":"story","title":"US-010: API Documentation & Health Checks","description":"As a developer, I want API documentation and health endpoints so that I can monitor and integrate with the platform.\n\n## Acceptance Criteria\n- [ ] OpenAPI/Swagger UI at `/docs` with all endpoints documented\n- [ ] `/health` endpoint returning database and service status\n- [ ] `/health/db` endpoint for database connectivity check\n- [ ] README in `apps/api/` with setup instructions\n- [ ] Architecture Decision Record (ADR) for httpOnly cookie auth choice\n- [ ] All quality gates passing\n\n## Quality Gates\n- `pytest` must pass\n- `mypy .` must pass\n- `ruff check .` must pass","status":"open","priority":3,"parent":"phase1-epic","external_ref":null,"dependencies":["phase1-003"]}
{"id":"phase2-epic","type":"epic","title":"Phase 2: Tool Runtime (Docker Tools, Web Access, Terminal)","description":"Extend Headquarter platform with Docker-based tool runtime system. Enables launching development tools (code-server, Jupyter, opencode) from git repositories, accessing their web UIs via Traefik routes, and interacting through web-based terminals. Tools are defined via Docker Compose templates and spawned into isolated containers.","status":"open","priority":0,"parent":null,"external_ref":"prd:./tasks/prd-phase2-tool-runtime.md","dependencies":[]}
{"id":"phase2-011","type":"story","title":"US-011: Tool Type Definition System","description":"As an admin, I want to define tool types using Docker Compose templates so that new tools can be added without code changes.\n\n## Acceptance Criteria\n- [ ] Create ToolType model with fields: name, description, docker_compose_template, icon, category, default_env_vars, default_ports\n- [ ] Support template variables: {{REPO_PATH}}, {{WORKSPACE_DIR}}, {{USER_ID}}, {{PROJECT_ID}}, {{TOOL_ID}}\n- [ ] Seed database with 3 built-in tool types: code-server, jupyter-notebook, opencode\n- [ ] GET /tool-types endpoint listing all available tool types\n- [ ] GET /tool-types/{id} returning tool type details\n- [ ] Docker Compose templates validated on create/update\n- [ ] Tests for template validation and variable substitution\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass","status":"open","priority":1,"parent":"phase2-epic","external_ref":null,"dependencies":[]}
{"id":"phase2-012","type":"story","title":"US-012: Tool Instance Model & Database Schema","description":"As a developer, I want a database model for tool instances so that I can track running tools and their state.\n\n## Acceptance Criteria\n- [ ] Create ToolInstance model with fields: id, name, project_id, repo_id, tool_type_id, status, container_id, subdomain, ports, env_vars, created_at, started_at, stopped_at\n- [ ] Status enum: pending, building, running, stopped, error\n- [ ] Relationship: ToolInstance belongs to Project and GitRepository\n- [ ] Alembic migration for tool instance tables\n- [ ] ToolInstanceConfig model for per-instance config overrides\n- [ ] Tests for model relationships and status transitions\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass","status":"open","priority":1,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-011"]}
{"id":"phase2-013","type":"story","title":"US-013: Docker Compose Template Engine","description":"As a developer, I want a template engine that renders Docker Compose files with project-specific values so that tools launch with correct configuration.\n\n## Acceptance Criteria\n- [ ] Implement template rendering engine (Jinja2 or string replacement)\n- [ ] Variable substitution: {{REPO_PATH}}, {{WORKSPACE_DIR}}, {{USER_ID}}, {{PROJECT_ID}}, {{TOOL_ID}}, {{SUBDOMAIN}}\n- [ ] Support environment variable injection from project/user config\n- [ ] Support port mapping configuration (host port allocation)\n- [ ] Support volume mounts (repo code, config files, persistent data)\n- [ ] Support network attachment (Traefik network)\n- [ ] Tests for template rendering with various inputs\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass","status":"open","priority":1,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-011"]}
{"id":"phase2-014","type":"story","title":"US-014: Tool Spawning & Docker Integration","description":"As a user, I want to launch tools from my repositories so that I can start working immediately.\n\n## Acceptance Criteria\n- [ ] POST /tools endpoint to create and launch a tool instance\n- [ ] Accept parameters: name, project_id, repo_id, tool_type_id, env_vars (optional), config_overrides (optional)\n- [ ] Generate unique subdomain: {tool-name}-{tool-id}.hq.local\n- [ ] Render Docker Compose template with project values\n- [ ] Execute docker compose up -d to launch container\n- [ ] Store container_id and update status to running\n- [ ] Handle build errors and set status to error with logs\n- [ ] POST /tools/{id}/stop to stop container (docker compose stop)\n- [ ] POST /tools/{id}/start to start stopped container\n- [ ] DELETE /tools/{id} to stop and remove container + data\n- [ ] Tests mocking Docker compose commands\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass","status":"open","priority":2,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-012","phase2-013"]}
{"id":"phase2-015","type":"story","title":"US-015: Traefik Route Generation","description":"As a user, I want tools accessible via clean URLs so that I can access them without remembering ports.\n\n## Acceptance Criteria\n- [ ] Auto-generate Traefik labels on tool containers:\n - traefik.enable=true\n - traefik.http.routers.{tool_id}.rule=Host({subdomain}.hq.local)\n - traefik.http.routers.{tool_id}.entrypoints=web\n - traefik.http.services.{tool_id}.loadbalancer.server.port={port}\n- [ ] Tools join Traefik Docker network for routing\n- [ ] GET /tools/{id}/url returning accessible URL\n- [ ] Handle subdomain collisions (append random suffix if needed)\n- [ ] Support HTTPS in production (websecure entrypoint)\n- [ ] Tests for label generation and URL construction\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass","status":"open","priority":2,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-014"]}
{"id":"phase2-016","type":"story","title":"US-016: Web-Based Terminal (xterm.js)","description":"As a user, I want a terminal in my browser to interact with running tools so that I don't need SSH access.\n\n## Acceptance Criteria\n- [ ] WebSocket endpoint /ws/terminal/{tool_id} for terminal sessions\n- [ ] Use docker exec to spawn shell in running container\n- [ ] Stream stdin/stdout/stderr via WebSocket\n- [ ] Terminal resize support (COLS, ROWS)\n- [ ] Session cleanup on disconnect\n- [ ] Authentication: only tool owner can access terminal\n- [ ] Frontend terminal component using xterm.js\n- [ ] Terminal connects to WebSocket with auth token\n- [ ] Support multiple terminal sessions per tool\n- [ ] Tests for WebSocket terminal lifecycle\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass\n- npm run typecheck must pass\n- npm run lint must pass","status":"open","priority":2,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-014"]}
{"id":"phase2-017","type":"story","title":"US-017: Tool Status Monitoring & Logs","description":"As a user, I want to see if my tools are running and view their logs so that I can debug issues.\n\n## Acceptance Criteria\n- [ ] GET /tools/{id}/status returning real-time container status\n- [ ] GET /tools/{id}/logs returning recent container logs (tail 100)\n- [ ] GET /tools/{id}/logs/stream WebSocket for live log streaming\n- [ ] Frontend tool dashboard showing:\n - Status indicator (running/stopped/error)\n - Uptime counter\n - Quick actions (start/stop/restart)\n - Log viewer with auto-scroll\n- [ ] Auto-refresh status every 5 seconds\n- [ ] Tests for status checking and log retrieval\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass\n- npm run typecheck must pass\n- npm run lint must pass","status":"open","priority":2,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-014"]}
{"id":"phase2-018","type":"story","title":"US-018: Frontend Tool Management UI","description":"As a user, I want a web interface to manage my tools so that I can launch, monitor, and access them easily.\n\n## Acceptance Criteria\n- [ ] Tools page listing all user's tool instances\n- [ ] Launch Tool button opening creation form\n- [ ] Form fields: name, project (dropdown), repository (dropdown), tool type (dropdown), environment variables (key-value), config overrides\n- [ ] Tool detail page showing:\n - Status and info\n - Open button (links to tool URL)\n - Terminal button (opens web terminal)\n - Logs panel\n - Settings (env vars, restart, delete)\n- [ ] Tool cards on dashboard showing quick stats\n- [ ] Empty state when no tools exist\n- [ ] Tests for tool CRUD operations in frontend\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass\n- npm run typecheck must pass\n- npm run lint must pass","status":"open","priority":2,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-015","phase2-016","phase2-017"]}
{"id":"phase2-019","type":"story","title":"US-019: Tool Configuration & Environment Variables","description":"As a user, I want to configure tools with custom environment variables and settings so that they work for my specific needs.\n\n## Acceptance Criteria\n- [ ] Global tool config at user level (default env vars for all tools)\n- [ ] Project-level tool config (overrides global defaults)\n- [ ] Tool instance-level config (overrides project defaults)\n- [ ] PATCH /tools/{id}/config endpoint for updating config\n- [ ] Config inheritance: user defaults -> project overrides -> instance overrides\n- [ ] Frontend config editor with key-value pairs\n- [ ] Support for secret values (masked in UI, encrypted at rest)\n- [ ] Tests for config inheritance and override logic\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass\n- npm run typecheck must pass\n- npm run lint must pass","status":"open","priority":3,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-014"]}
{"id":"phase2-020","type":"story","title":"US-020: Phase 2 Integration & Documentation","description":"As a developer, I want Phase 2 fully integrated with Phase 1 and documented so that the platform is cohesive.\n\n## Acceptance Criteria\n- [ ] Tool instances linked to projects and repositories (Phase 1 models)\n- [ ] SSH keys from Phase 1 available inside tool containers (mount ~/.ssh)\n- [ ] Git user config from Phase 1 applied to tool containers\n- [ ] Update API docs with all new endpoints\n- [ ] README section for tool configuration\n- [ ] Architecture Decision Record (ADR) for Docker Compose template approach\n- [ ] All quality gates passing\n- [ ] End-to-end test: create repo -> launch code-server -> access via URL -> open terminal\n\n## Quality Gates\n- pytest must pass\n- mypy . must pass\n- ruff check . must pass\n- npm run typecheck must pass\n- npm run lint must pass","status":"open","priority":3,"parent":"phase2-epic","external_ref":null,"dependencies":["phase2-015","phase2-016","phase2-017","phase2-018","phase2-019"]}
-54
View File
@@ -1,54 +0,0 @@
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: JSONL-only, no Dolt database
# When true, bd will use .beads/issues.jsonl as the source of truth
# no-db: false
# Enable JSON output by default
# json: false
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
# 0 = hide titles, N > 0 = truncate to N characters
# output:
# title-length: 255
# Default actor for audit trails (overridden by BEADS_ACTOR or --actor)
# actor: ""
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
# When enabled, new events are appended incrementally using a high-water mark.
# Use 'bd export --events' to trigger manually regardless of this setting.
# events-export: false
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct database
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# JSONL backup (periodic export for off-machine recovery)
# Auto-enabled when a git remote exists. Override explicitly:
# backup:
# enabled: false # Disable auto-backup entirely
# interval: 15m # Minimum time between auto-exports
# git-push: false # Disable git push (export locally only)
# git-repo: "" # Separate git repo for backups (default: project repo)
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.3 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run post-checkout "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run post-checkout "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'post-checkout'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.3 ---
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.3 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run post-merge "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run post-merge "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'post-merge'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.3 ---
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.3 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run pre-commit "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run pre-commit "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'pre-commit'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.3 ---
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.3 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run pre-push "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run pre-push "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'pre-push'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.3 ---
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.3 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.3 ---
-7
View File
@@ -1,7 +0,0 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "embedded",
"dolt_database": "headquarter",
"project_id": "d9aa6d40-ce59-4c2c-8cea-f33879137a56"
}
-26
View File
@@ -1,26 +0,0 @@
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
],
"SessionStart": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
]
}
}
-15
View File
@@ -1,15 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_size = 4
[Makefile]
indent_style = tab
+18 -36
View File
@@ -1,42 +1,24 @@
# App identity
APP_NAME=Headquarter
ROOT_DOMAIN=localhost
TOOL_DOMAIN=tools.localhost
# API / Web URLs
API_URL=http://localhost:8000
WEB_URL=http://localhost:5173
CORS_ORIGINS=http://localhost:5173
# Database (local development)
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
# Database Configuration
POSTGRES_USER=headquarter
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=headquarter
# DATABASE_URL uses a literal value because Pydantic Settings does not expand
# shell-style variable interpolation from .env files.
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/headquarter
# Authentik OIDC placeholders (wire in FN-004)
AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/
AUTHENTIK_CLIENT_ID=your-client-id
AUTHENTIK_CLIENT_SECRET=your-client-secret
# Redis Configuration
REDIS_URL=redis://redis:6379/0
# Traefik / deployment placeholders (wire in FN-006)
TRAEFIK_NETWORK=traefik
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_CERT_RESOLVER=letsencrypt
TRAEFIK_LOG_LEVEL=INFO
TRAEFIK_ACME_EMAIL=admin@example.com
TOOL_SUBDOMAIN_PATTERN={tool}-{project}-{user}.tools.localhost
# JWT Configuration
JWT_SECRET=change-me-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRATION_HOURS=24
# Frontend build-time variables (passed to web container)
# Application Configuration
APP_ENV=development
DEBUG=true
LOG_LEVEL=info
REPO_BASE_PATH=/data/repos
# Frontend Configuration
VITE_API_URL=http://localhost:8000
VITE_OIDC_ISSUER=https://auth.example.com/application/o/headquarter/
VITE_OIDC_CLIENT_ID=your-client-id
VITE_OIDC_REDIRECT_URI=https://headquarter.commumedia.org/callback
# Secrets (generate strong random values for production)
SECRET_ENCRYPTION_KEY=change-me-in-production
# Auth dev bypass (local development only — NEVER enable in production)
AUTH_DEV_BYPASS=false
# Docker Configuration
COMPOSE_PROJECT_NAME=headquarter
-87
View File
@@ -1,87 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
web-ci:
name: Web CI
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm --filter @headquarter/web lint
- name: Typecheck
run: pnpm --filter @headquarter/web typecheck
- name: Test
run: pnpm --filter @headquarter/web test
api-ci:
name: API CI
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: headquarter_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install API dev dependencies
working-directory: apps/api
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Ruff check
working-directory: apps/api
run: ruff check app/ tests/
- name: Mypy
working-directory: apps/api
run: mypy app/ tests/
- name: Pytest
working-directory: apps/api
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/headquarter_test
run: pytest
+149
View File
@@ -0,0 +1,149 @@
---
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+154
View File
@@ -0,0 +1,154 @@
---
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+170
View File
@@ -0,0 +1,170 @@
---
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+103
View File
@@ -0,0 +1,103 @@
---
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+288
View File
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: 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.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx-explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+110
View File
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: 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.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+77 -64
View File
@@ -1,84 +1,97 @@
# Agent Instructions
# AGENTS.md
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
## Core rule
## Quick Reference
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work atomically
bd close <id> # Complete work
bd dolt push # Push beads data to remote
```
## Priority order
## Non-Interactive Shell Commands
1. Current user instruction
2. OpenSpec proposal, tasks, and spec deltas
3. This `AGENTS.md`
4. Existing project conventions
5. Agent assumptions
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
When instructions conflict, follow the higher-priority source. Do not silently expand scope.
Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input.
## Default workflow
**Use these forms instead:**
```bash
# Force overwrite without prompting
cp -f source dest # NOT: cp source dest
mv -f source dest # NOT: mv source dest
rm -f file # NOT: rm file
For any non-trivial change:
# For recursive operations
rm -rf directory # NOT: rm -r directory
cp -rf source dest # NOT: cp -r source dest
```
1. Read the relevant OpenSpec change, tasks, and spec deltas.
2. Use `brainstorming` if scope, design, or requirements are unclear.
3. Use `writing-plans` before implementation.
4. Implement only the selected task or clearly requested change.
5. Use tests, typecheck, lint, or targeted checks to verify.
6. Use `verification-before-completion` before claiming completion.
**Other commands that may prompt:**
- `scp` - use `-o BatchMode=yes` for non-interactive
- `ssh` - use `-o BatchMode=yes` to fail instead of prompting
- `apt-get` - use `-y` flag
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
If namespacing is required, use:
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
## Beads Issue Tracker
* `superpowers:brainstorming`
* `superpowers:writing-plans`
* `superpowers:test-driven-development`
* `superpowers:systematic-debugging`
* `superpowers:verification-before-completion`
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
## When OpenSpec is required
### Quick Reference
Create or update an OpenSpec change before implementing:
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # Complete work
```
* New features
* Behavior changes
* API changes
* Database/schema changes
* Auth, security, billing, permissions, or data handling changes
* Architecture changes
* Large refactors
* Anything with unclear acceptance criteria
### Rules
Small local fixes may skip OpenSpec if they do not change behavior or public contracts.
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
## Superpowers usage
## Session Completion
Use:
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
* `brainstorming` for ambiguity, design choices, or scope questions.
* `writing-plans` for multi-step or multi-file work.
* `test-driven-development` for behavior changes and bug fixes where practical.
* `systematic-debugging` for failing tests or unclear bugs.
* `verification-before-completion` before final completion claims.
* `using-git-worktrees` only for isolated risky or parallel work.
* `dispatching-parallel-agents` only for independent subtasks with clear boundaries.
**MANDATORY WORKFLOW:**
If a skill is unavailable, follow its intent manually and say so.
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd dolt push
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
## Scope discipline
Do not:
* Implement outside the selected OpenSpec task.
* Mix unrelated cleanup with feature work.
* 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.
If scope must change, propose an OpenSpec update first.
## Verification
Before completion, report:
* What changed
* Which OpenSpec task/change it addresses
* Tests/checks run
* Any failures, skipped checks, assumptions, or risks
Do not claim completion without verification evidence.
## Definition of done
A task is done when:
* It matches OpenSpec.
* The diff is focused.
* Relevant tests/checks passed or limitations are stated.
* No unrelated scope was added.
* Remaining risks or follow-ups are documented.
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds
<!-- END BEADS INTEGRATION -->
-69
View File
@@ -1,69 +0,0 @@
# Project Instructions for AI Agents
This file provides instructions and context for AI coding agents working on this project.
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
## Beads Issue Tracker
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
### Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # Complete work
```
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
## Session Completion
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd dolt push
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds
<!-- END BEADS INTEGRATION -->
## Build & Test
_Add your build and test commands here_
```bash
# Example:
# npm install
# npm test
```
## Architecture Overview
_Add a brief overview of your project architecture_
## Conventions & Patterns
_Add your project-specific conventions here_
+94
View File
@@ -0,0 +1,94 @@
.PHONY: help up down logs migrate test lint clean build
# Default target
help:
@echo "Headquarter Development Commands"
@echo "================================"
@echo "make up - Start all services"
@echo "make down - Stop all services"
@echo "make logs - View service logs"
@echo "make migrate - Run database migrations"
@echo "make test - Run test suites"
@echo "make lint - Run linting"
@echo "make build - Build all Docker images"
@echo "make clean - Remove containers and volumes"
@echo "make shell - Open shell in API container"
# Start services
up:
docker compose up -d
@echo "Services starting..."
@echo "API: http://localhost:8000"
@echo "Web: http://localhost:3000"
@echo "Postgres: localhost:5432"
@echo "Redis: localhost:6379"
# Stop services
down:
docker compose down
# View logs
logs:
docker compose logs -f
# View specific service logs
logs-api:
docker compose logs -f api
logs-web:
docker compose logs -f web
logs-db:
docker compose logs -f postgres
# Run database migrations
migrate:
docker compose exec api alembic upgrade head
# Create new migration
migration:
docker compose exec api alembic revision --autogenerate -m "$(message)"
# Run tests
test:
docker compose exec api pytest -v
# Run linting
lint:
docker compose exec api ruff check .
docker compose exec api mypy .
cd apps/web && npm run lint
# Type checking
typecheck:
docker compose exec api mypy .
cd apps/web && npm run typecheck
# Build all images
build:
docker compose build
# Build specific service
build-api:
docker compose build api
build-web:
docker compose build web
# Clean up
clean:
docker compose down -v --remove-orphans
docker system prune -f
# Open shell in API container
shell:
docker compose exec api /bin/sh
# Database shell
db-shell:
docker compose exec postgres psql -U $(POSTGRES_USER) -d $(POSTGRES_DB)
# Health check
health:
@echo "Checking service health..."
@docker compose ps
-137
View File
@@ -1,137 +0,0 @@
# Headquarter
Hosted workspace and tool-orchestration platform where authenticated users create projects, connect Git repositories, and spawn self-hosted tools such as OpenCode and code-server.
## Current Status
This repository provides:
- React + Vite + TypeScript frontend (`apps/web`)
- FastAPI + Python backend (`apps/api`)
- Manifest-driven tool registry with built-in OpenCode and code-server definitions
- Root monorepo tooling (pnpm workspace, Makefile)
- Docker Compose local development stack
- Deployment skeleton for Portainer + Traefik
- Automated tests (Vitest + pytest)
## Repository Layout
```text
├── apps/
│ ├── web/ # React frontend
│ └── api/ # FastAPI backend
├── packages/ # Shared packages (future)
├── docs/ # Architecture, development, and deployment docs
├── deploy/ # Portainer/Traefik deployment examples
├── docker-compose.yml
├── docker-compose.traefik.yml
├── package.json # Root monorepo scripts
├── Makefile # Common local workflows
└── .env.example # Shared environment variables
```
## Prerequisites
- Node.js ≥ 20 and pnpm ≥ 9
- Python ≥ 3.11
- Docker and Docker Compose (optional, for local Postgres)
## Quickstart
```bash
# Install dependencies
make install
# Copy environment examples
cp .env.example .env
cp apps/web/.env.example apps/web/.env
# Run tests
make test
# Start frontend and backend in development mode
make dev
```
### Docker Compose
```bash
docker compose up --build -d
```
This starts the API, web frontend, and PostgreSQL.
## Commands
| Command | Description |
|---------|-------------|
| `make install` | Install Node and Python dependencies |
| `make dev` | Start frontend and backend in parallel |
| `make test` | Run frontend and backend tests |
| `make lint` | Run linters |
| `make typecheck` | Run type checkers |
| `make build` | Build frontend and backend |
| `make compose-up` | Start Docker Compose stack |
| `make compose-down` | Stop Docker Compose stack |
## Continuous Integration
All pull requests and pushes to `main` are validated by a GitHub Actions workflow (`.github/workflows/ci.yml`). The workflow runs the frontend and backend quality gates in parallel:
- **Web CI** — lint, typecheck, and test the React frontend.
- **API CI** — lint with `ruff`, typecheck with `mypy`, and run `pytest` against a PostgreSQL service container.
See [Development](docs/development.md) for details on running these checks locally.
## Documentation
- [Architecture](docs/architecture.md) — System design and MVP phases
- [Development](docs/development.md) — Local setup and day-to-day commands
- [Deployment](docs/deployment.md) — Portainer/Traefik assumptions
## Frontend Environment Variables
The frontend (`apps/web`) requires these environment variables:
| Variable | Description |
|----------|-------------|
| `VITE_API_URL` | Backend API base URL |
| `VITE_OIDC_ISSUER` | OIDC provider issuer URL |
| `VITE_OIDC_CLIENT_ID` | OIDC client ID |
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL |
Copy `apps/web/.env.example` to `apps/web/.env` and fill in your values.
## Deployment
Deploy to production using Docker Compose:
```bash
# Copy and configure production environment
cp deploy/.env.example deploy/.env
# Edit deploy/.env with your domain and secrets
# Deploy locally for testing
docker compose -f docker-compose.prod.yml up --build -d
# Or deploy via Portainer using deploy/portainer-stack.yml
```
See [Deployment Guide](docs/deployment.md) for full details.
## Scope Boundaries
This scaffold intentionally defers detailed implementation to follow-up tasks:
- **FN-004** — Backend domain models, database migrations, API endpoints, auth integration
- **FN-005** — Frontend dashboard navigation, project creation, authenticated flows
- **FN-006** — Full deployment automation, dynamic Traefik labels for spawned tool containers
- **FN-003** — Manifest-driven tool registry
- **FN-007** — Provider-independent Git connection model
- **FN-008** — OpenCode terminal environment proof of concept
- **FN-009** — Persistent config and secrets handling
- **FN-010** — code-server manifest and spawn flow
## License
TBD
-15
View File
@@ -1,15 +0,0 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.venv/
venv/
ENV/
env/
*.egg-info/
dist/
build/
.git/
.env
.env.local
*.log
-1
View File
@@ -1 +0,0 @@
3.10.18
+41 -8
View File
@@ -1,18 +1,51 @@
FROM python:3.12-slim
# Build stage
FROM python:3.11-slim as builder
WORKDIR /build
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY pyproject.toml .
RUN pip install --no-cache-dir --user -e ".[dev]"
# Production stage
FROM python:3.11-slim
# Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
git \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Copy dependencies from builder
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
COPY app/ ./app/
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e "."
# Copy application code
COPY --chown=appuser:appgroup . .
# Create directories for repo storage
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
-19
View File
@@ -1,19 +0,0 @@
.PHONY: revision upgrade downgrade lint test typecheck
revision:
.venv/bin/alembic revision --autogenerate -m "$(msg)"
upgrade:
.venv/bin/alembic upgrade head
downgrade:
.venv/bin/alembic downgrade -1
lint:
.venv/bin/ruff check app tests
test:
.venv/bin/pytest
typecheck:
.venv/bin/mypy app tests
-149
View File
@@ -1,149 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-76
View File
@@ -1,76 +0,0 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Build async URL from settings
database_url = settings.database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", database_url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
-28
View File
@@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -1,51 +0,0 @@
"""add repository_connection
Revision ID: 42a78fd41e23
Revises: 6cfa61694d0a
Create Date: 2026-05-14 08:19:37.912177
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '42a78fd41e23'
down_revision: Union[str, Sequence[str], None] = '6cfa61694d0a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('repository_connection',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('repository_id', sa.Uuid(), nullable=True),
sa.Column('provider_kind', sa.String(length=50), nullable=False),
sa.Column('credential_id', sa.Uuid(), nullable=True),
sa.Column('connection_status', sa.String(length=50), nullable=False),
sa.Column('default_branch', sa.String(length=100), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_repository_connection_credential_id'), 'repository_connection', ['credential_id'], unique=False)
op.create_index(op.f('ix_repository_connection_project_id'), 'repository_connection', ['project_id'], unique=False)
op.create_index(op.f('ix_repository_connection_repository_id'), 'repository_connection', ['repository_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_repository_connection_repository_id'), table_name='repository_connection')
op.drop_index(op.f('ix_repository_connection_project_id'), table_name='repository_connection')
op.drop_index(op.f('ix_repository_connection_credential_id'), table_name='repository_connection')
op.drop_table('repository_connection')
# ### end Alembic commands ###
@@ -1,172 +0,0 @@
"""initial schema
Revision ID: 6cfa61694d0a
Revises:
Create Date: 2026-05-14 06:16:27.700389
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6cfa61694d0a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('secret',
sa.Column('scope_type', sa.String(length=50), nullable=False),
sa.Column('scope_id', sa.Uuid(), nullable=False),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('encrypted_value', sa.Text(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope_type', 'scope_id', 'key')
)
op.create_index(op.f('ix_secret_scope_id'), 'secret', ['scope_id'], unique=False)
op.create_table('tool_definition',
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('version', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image', sa.Text(), nullable=False),
sa.Column('manifest_data', sa.JSON(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tool_definition_key'), 'tool_definition', ['key'], unique=True)
op.create_table('user',
sa.Column('authentik_sub', sa.String(length=255), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_authentik_sub'), 'user', ['authentik_sub'], unique=True)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_table('config',
sa.Column('scope_type', sa.String(length=50), nullable=False),
sa.Column('scope_id', sa.Uuid(), nullable=False),
sa.Column('tool_definition_id', sa.Uuid(), nullable=True),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope_type', 'scope_id', 'tool_definition_id', 'key')
)
op.create_index(op.f('ix_config_scope_id'), 'config', ['scope_id'], unique=False)
op.create_index(op.f('ix_config_tool_definition_id'), 'config', ['tool_definition_id'], unique=False)
op.create_table('project',
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('owner_id', 'slug')
)
op.create_index(op.f('ix_project_owner_id'), 'project', ['owner_id'], unique=False)
op.create_table('repository',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('git_url', sa.Text(), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('default_branch', sa.String(length=100), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_repository_project_id'), 'repository', ['project_id'], unique=False)
op.create_table('tool_instance',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('tool_definition_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('container_id', sa.String(length=255), nullable=True),
sa.Column('subdomain', sa.String(length=255), nullable=True),
sa.Column('config_override', sa.JSON(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('subdomain')
)
op.create_index(op.f('ix_tool_instance_project_id'), 'tool_instance', ['project_id'], unique=False)
op.create_index(op.f('ix_tool_instance_tool_definition_id'), 'tool_instance', ['tool_definition_id'], unique=False)
op.create_table('workspace',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('mount_path', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workspace_project_id'), 'workspace', ['project_id'], unique=False)
op.create_table('access_route',
sa.Column('tool_instance_id', sa.Uuid(), nullable=False),
sa.Column('domain', sa.Text(), nullable=False),
sa.Column('path_prefix', sa.String(length=255), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('provider_config', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tool_instance_id'], ['tool_instance.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_access_route_tool_instance_id'), 'access_route', ['tool_instance_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_access_route_tool_instance_id'), table_name='access_route')
op.drop_table('access_route')
op.drop_index(op.f('ix_workspace_project_id'), table_name='workspace')
op.drop_table('workspace')
op.drop_index(op.f('ix_tool_instance_tool_definition_id'), table_name='tool_instance')
op.drop_index(op.f('ix_tool_instance_project_id'), table_name='tool_instance')
op.drop_table('tool_instance')
op.drop_index(op.f('ix_repository_project_id'), table_name='repository')
op.drop_table('repository')
op.drop_index(op.f('ix_project_owner_id'), table_name='project')
op.drop_table('project')
op.drop_index(op.f('ix_config_tool_definition_id'), table_name='config')
op.drop_index(op.f('ix_config_scope_id'), table_name='config')
op.drop_table('config')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_index(op.f('ix_user_authentik_sub'), table_name='user')
op.drop_table('user')
op.drop_index(op.f('ix_tool_definition_key'), table_name='tool_definition')
op.drop_table('tool_definition')
op.drop_index(op.f('ix_secret_scope_id'), table_name='secret')
op.drop_table('secret')
# ### end Alembic commands ###
View File
-3
View File
@@ -1,3 +0,0 @@
from app.auth.dependencies import get_current_active_user, get_current_user
__all__ = ["get_current_user", "get_current_active_user"]
-150
View File
@@ -1,150 +0,0 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import decode_token
from app.config import settings
from app.db import get_db_session
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
async def _get_or_create_dev_user(session: AsyncSession) -> User:
result = await session.execute(
select(User).where(User.authentik_sub == "dev-user")
)
user = result.scalar_one_or_none()
if user is None:
result = await session.execute(
select(User).where(User.email == "dev@localhost")
)
user = result.scalar_one_or_none()
if user is None:
user = User(
authentik_sub="dev-user",
email="dev@localhost",
display_name="Dev User",
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def get_current_user(
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
if token is None:
if settings.debug and settings.auth_dev_bypass:
return await _get_or_create_dev_user(session)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = await decode_token(token.credentials)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
authentik_sub = claims.get("sub")
email = claims.get("email", "")
display_name = claims.get("name") or claims.get("preferred_username") or email
if not authentik_sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing 'sub' claim",
headers={"WWW-Authenticate": "Bearer"},
)
result = await session.execute(
select(User).where(User.authentik_sub == authentik_sub)
)
user = result.scalar_one_or_none()
if user is None:
result = await session.execute(
select(User).where(User.email == email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email {email} already exists",
)
user = User(
authentik_sub=authentik_sub,
email=email,
display_name=display_name,
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
return current_user
async def validate_traefik_auth(
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = await decode_token(token.credentials)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
authentik_sub = claims.get("sub")
if not authentik_sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing 'sub' claim",
headers={"WWW-Authenticate": "Bearer"},
)
result = await session.execute(
select(User).where(User.authentik_sub == authentik_sub)
)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return user
-58
View File
@@ -1,58 +0,0 @@
from typing import Any
import httpx
import jwt
from app.config import settings
_jwks_cache: dict[str, Any] | None = None
async def decode_token(token: str) -> dict[str, Any]:
if settings.authentik_issuer_url:
issuer = settings.authentik_issuer_url.rstrip("/")
jwks = await _get_jwks(issuer)
signing_key = jwt.algorithms.RSAAlgorithm.from_jwk(
_find_matching_key(jwks, token)
)
return jwt.decode(
token,
signing_key, # type: ignore[arg-type]
algorithms=["RS256"],
audience=settings.authentik_client_id,
issuer=settings.authentik_issuer_url,
)
return jwt.decode(token, options={"verify_signature": False})
async def _get_jwks(issuer: str) -> dict[str, Any]:
global _jwks_cache
if _jwks_cache is not None:
return _jwks_cache
discovery_url = f"{issuer}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
discovery = resp.json()
jwks_uri = discovery["jwks_uri"]
jwks_resp = await client.get(jwks_uri)
jwks_resp.raise_for_status()
_jwks_cache = jwks_resp.json()
return _jwks_cache
def _find_matching_key(jwks: dict[str, Any], token: str) -> dict[str, Any]:
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
for key in jwks.get("keys", []):
key_dict: dict[str, Any] = key
if key_dict.get("kid") == kid:
return key_dict
raise RuntimeError(f"No matching JWKS key found for kid={kid}")
-36
View File
@@ -1,36 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Headquarter API"
debug: bool = False
api_v1_prefix: str = "/api/v1"
# Authentik OIDC
authentik_issuer_url: str = ""
authentik_client_id: str = ""
authentik_client_secret: str = ""
# Database
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
# CORS
cors_origins: str = "http://localhost:5173,http://localhost:3000"
# Deployment
root_domain: str = "localhost"
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
# Auth & encryption
secret_encryption_key: str = "change-me-in-production"
access_token_expire_minutes: int = 60
auth_dev_bypass: bool = False
settings = Settings()
-25
View File
@@ -1,25 +0,0 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
# Rewrite sync postgres URL to asyncpg
DATABASE_URL = settings.database_url
if DATABASE_URL.startswith("postgresql://"):
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(DATABASE_URL, echo=settings.debug)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
-30
View File
@@ -1,30 +0,0 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.config import settings
def _derive_fernet_key(key: str) -> bytes:
"""Derive a URL-safe base64-encoded 32-byte Fernet key from any string."""
digest = hashlib.sha256(key.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
_fernet = Fernet(_derive_fernet_key(settings.secret_encryption_key))
def encrypt_value(plain_text: str) -> str:
"""Encrypt a plaintext string and return the ciphertext as a string."""
token = _fernet.encrypt(plain_text.encode("utf-8"))
return token.decode("utf-8")
def decrypt_value(cipher_text: str) -> str:
"""Decrypt a ciphertext string and return the plaintext."""
try:
plain = _fernet.decrypt(cipher_text.encode("utf-8"))
except InvalidToken as exc:
raise RuntimeError("Invalid encryption token — secret cannot be decrypted") from exc
return plain.decode("utf-8")
-25
View File
@@ -1,25 +0,0 @@
"""Git provider abstraction, credentials, SSH keys, and operations."""
from app.git.connection import ConnectionManager, RepositoryConnectionData
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
from app.git.operations import GitOperations, LocalGitOperations
from app.git.provider import GitProvider
from app.git.ssh_key import SshKeyLifecycle, SshKeyPair
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
__all__ = [
"AccessTokenCredential",
"ConnectionManager",
"ConnectionStatus",
"CredentialKind",
"CredentialStorage",
"GitCredential",
"GitOperations",
"GitProvider",
"LocalGitOperations",
"ProviderKind",
"RepositoryConnectionData",
"SshKeyLifecycle",
"SshKeyPair",
"SshKeyStatus",
]
-100
View File
@@ -1,100 +0,0 @@
"""Repository connection orchestration."""
import uuid
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.git.credentials import CredentialStorage, GitCredential
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
from app.models.repository_connection import RepositoryConnection
class RepositoryConnectionData(BaseModel):
"""Domain-level read model for a repository connection."""
id: uuid.UUID
project_id: uuid.UUID
repository_id: uuid.UUID | None
provider_kind: ProviderKind
credential_id: uuid.UUID | None
connection_status: ConnectionStatus
default_branch: str | None
class ConnectionManager:
"""Orchestrates creating, validating, and retrieving repository connections."""
def __init__(self, provider: GitProvider, storage: CredentialStorage) -> None:
self.provider = provider
self.storage = storage
async def connect(
self,
session: AsyncSession,
project_id: uuid.UUID,
git_url: str,
credential: GitCredential,
) -> RepositoryConnectionData:
"""Store *credential*, create a connection row, and validate with the provider."""
credential_id = self.storage.create(credential)
row = RepositoryConnection(
project_id=project_id,
provider_kind=str(self.provider.get_kind()),
credential_id=credential_id,
connection_status=str(ConnectionStatus.pending),
)
session.add(row)
await session.flush()
try:
status = self.provider.validate_connection(
git_url, str(credential_id)
)
except Exception:
row.connection_status = str(ConnectionStatus.error)
await session.flush()
raise RuntimeError("Connection validation failed")
if status == ConnectionStatus.connected:
row.connection_status = str(ConnectionStatus.connected)
else:
row.connection_status = str(ConnectionStatus.error)
await session.flush()
raise RuntimeError("Connection validation failed")
await session.flush()
return _map_row(row)
async def disconnect(
self, session: AsyncSession, connection_id: uuid.UUID
) -> None:
"""Mark the connection as disconnected."""
row = await session.get(RepositoryConnection, connection_id)
if row is None:
return
row.connection_status = str(ConnectionStatus.disconnected)
await session.flush()
async def get_connection(
self, session: AsyncSession, connection_id: uuid.UUID
) -> RepositoryConnectionData | None:
"""Fetch a connection by ID and map it to the Pydantic read model."""
row = await session.get(RepositoryConnection, connection_id)
if row is None:
return None
return _map_row(row)
def _map_row(row: RepositoryConnection) -> RepositoryConnectionData:
return RepositoryConnectionData(
id=row.id,
project_id=row.project_id,
repository_id=row.repository_id,
provider_kind=ProviderKind(row.provider_kind),
credential_id=row.credential_id,
connection_status=ConnectionStatus(row.connection_status),
default_branch=row.default_branch,
)
-39
View File
@@ -1,39 +0,0 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.git.credentials import CredentialStorage, GitCredential
from app.models.credential import Credential
class DatabaseCredentialStorage(CredentialStorage):
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create(self, credential: GitCredential) -> uuid.UUID:
row = Credential(
id=credential.id,
kind=str(credential.kind),
encrypted_payload=credential.encrypted_payload,
)
self.session.add(row)
await self.session.flush()
return row.id
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
row = await self.session.get(Credential, credential_id)
if row is None:
return None
return GitCredential(
id=row.id,
kind=row.kind,
encrypted_payload=row.encrypted_payload,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def delete(self, credential_id: uuid.UUID) -> None:
row = await self.session.get(Credential, credential_id)
if row is not None:
await self.session.delete(row)
await self.session.flush()
-52
View File
@@ -1,52 +0,0 @@
"""Credential models and storage interface.
Security rules:
- No plaintext ``private_key`` or ``token`` fields exist on any model class.
- The ``encrypted_payload`` field is opaque bytes encoded as a string.
"""
import abc
import uuid
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict, Field
from app.git.types import CredentialKind
class GitCredential(BaseModel):
"""Base credential model.
Never stores plaintext secrets. The ``encrypted_payload`` field holds
opaque encrypted data.
"""
model_config = ConfigDict(extra="forbid")
id: uuid.UUID = Field(default_factory=uuid.uuid4)
kind: CredentialKind
encrypted_payload: str = Field(repr=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
class AccessTokenCredential(GitCredential):
"""Access-token credential discriminated by ``kind``."""
kind: CredentialKind = CredentialKind.access_token
class CredentialStorage(abc.ABC):
"""Abstract storage backend for :class:`GitCredential` records."""
@abc.abstractmethod
async def create(self, credential: GitCredential) -> uuid.UUID:
"""Persist *credential* and return its ID."""
@abc.abstractmethod
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
"""Retrieve a credential by ID, or ``None`` if not found."""
@abc.abstractmethod
async def delete(self, credential_id: uuid.UUID) -> None:
"""Remove a credential by ID."""
-100
View File
@@ -1,100 +0,0 @@
import abc
import subprocess
from pathlib import Path
from typing import Any
class GitOperations(abc.ABC):
@abc.abstractmethod
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
pass
@abc.abstractmethod
def fetch(self, repo_path: Path, credential_id: str) -> None:
pass
@abc.abstractmethod
def push(self, repo_path: Path, credential_id: str) -> None:
pass
@abc.abstractmethod
def get_status(self, repo_path: Path) -> dict[str, Any]:
pass
class LocalGitOperations(GitOperations):
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
cmd = ["git", "clone", git_url, str(dest)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git clone failed: {result.stderr}")
def fetch(self, repo_path: Path, credential_id: str) -> None:
cmd = ["git", "-C", str(repo_path), "fetch", "--all"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git fetch failed: {result.stderr}")
def push(self, repo_path: Path, credential_id: str) -> None:
cmd = ["git", "-C", str(repo_path), "push"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git push failed: {result.stderr}")
def get_status(self, repo_path: Path) -> dict[str, Any]:
if not repo_path.exists() or not (repo_path / ".git").is_dir():
raise RuntimeError("Not a git repository")
try:
branch_result = subprocess.run(
["git", "-C", str(repo_path), "branch", "--show-current"],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
branch = branch_result.stdout.strip()
status_result = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError("Git command failed") from exc
untracked: list[str] = []
modified: list[str] = []
staged: list[str] = []
deleted: list[str] = []
for line in status_result.stdout.splitlines():
if len(line) < 3:
continue
index_status = line[0]
worktree_status = line[1]
filename = line[3:]
if index_status == "?" and worktree_status == "?":
untracked.append(filename)
elif index_status in ("M", "A"):
staged.append(filename)
if index_status == "D" or worktree_status == "D":
deleted.append(filename)
if worktree_status == "M":
modified.append(filename)
clean = not (untracked or modified or staged or deleted)
return {
"branch": branch,
"clean": clean,
"untracked": untracked,
"modified": modified,
"staged": staged,
"deleted": deleted,
}
-48
View File
@@ -1,48 +0,0 @@
"""Abstract base class for Git provider adapters."""
import abc
from typing import Any
from app.git.types import ConnectionStatus, ProviderKind
class GitProvider(abc.ABC):
"""Provider API adapter for remote Git operations.
This abstraction is separate from :class:`~app.git.operations.GitOperations`,
which handles local Git subprocess workflows.
"""
@abc.abstractmethod
def get_kind(self) -> ProviderKind:
"""Return the provider kind identifier."""
@abc.abstractmethod
def validate_connection(
self, git_url: str, credential_id: str
) -> ConnectionStatus:
"""Validate that the given credential can access *git_url*.
Returns a :class:`ConnectionStatus` indicating the result.
"""
@abc.abstractmethod
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
"""List repositories accessible with *credential_id*."""
@abc.abstractmethod
def create_deploy_key(
self, git_url: str, public_key: str
) -> str:
"""Register a deploy key on the remote provider.
Returns the provider-side deploy key ID.
"""
@abc.abstractmethod
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
"""Remove a previously registered deploy key."""
@abc.abstractmethod
def get_default_branch(self, git_url: str, credential_id: str) -> str:
"""Return the default branch name for the repository at *git_url*."""
-17
View File
@@ -1,17 +0,0 @@
from app.git.provider import GitProvider
from app.git.types import ProviderKind
from .github import GitHubAdapter
from .gitlab import GitLabAdapter
PROVIDERS: dict[ProviderKind, type[GitProvider]] = {
ProviderKind.github: GitHubAdapter,
ProviderKind.gitlab: GitLabAdapter,
}
def get_provider(kind: ProviderKind) -> GitProvider:
provider_class = PROVIDERS.get(kind)
if provider_class is None:
raise ValueError(f"Unsupported provider kind: {kind}")
return provider_class()
-40
View File
@@ -1,40 +0,0 @@
from typing import Any
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
class GitHubAdapter(GitProvider):
BASE_URL = "https://api.github.com"
def get_kind(self) -> ProviderKind:
return ProviderKind.github
def _get_headers(self, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _extract_owner_repo(self, git_url: str) -> tuple[str, str]:
clean = git_url.replace("https://github.com/", "")
clean = clean.replace("git@github.com:", "")
clean = clean.replace(".git", "")
parts = clean.split("/")
return parts[0], parts[1]
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return ""
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
-35
View File
@@ -1,35 +0,0 @@
from typing import Any
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
class GitLabAdapter(GitProvider):
BASE_URL = "https://gitlab.com/api/v4"
def get_kind(self) -> ProviderKind:
return ProviderKind.gitlab
def _get_headers(self, token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _extract_project_path(self, git_url: str) -> str:
path = git_url.replace("https://gitlab.com/", "")
path = path.replace("git@gitlab.com:", "")
path = path.replace(".git", "")
return path
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return ""
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
-82
View File
@@ -1,82 +0,0 @@
"""SSH key pair generation and lifecycle management.
Security rules:
- Private key material must never appear in logs, exceptions, ``__repr__``,
or test output.
- The ``encrypted_private_key`` field uses ``repr=False``.
"""
import uuid
from datetime import UTC, datetime
from pydantic import BaseModel, Field
from app.git.types import SshKeyStatus
def encrypt_private_key(raw: bytes) -> str:
from app.encryption import encrypt_value
return encrypt_value(raw.decode("utf-8"))
class SshKeyPair(BaseModel):
"""An Ed25519 SSH key pair belonging to a repository connection."""
id: uuid.UUID = Field(default_factory=uuid.uuid4)
connection_id: uuid.UUID
public_key: str
encrypted_private_key: str = Field(repr=False)
status: SshKeyStatus = SshKeyStatus.generated
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
revoked_at: datetime | None = None
class SshKeyLifecycle:
"""Generate and transition SSH key pairs."""
@staticmethod
def generate(connection_id: uuid.UUID) -> SshKeyPair:
"""Generate a new Ed25519 key pair for *connection_id*."""
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
)
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
public_key_pem = public_key.public_bytes(
Encoding.OpenSSH, PublicFormat.OpenSSH
).decode("utf-8")
private_key_pem = private_key.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
)
encrypted = encrypt_private_key(private_key_pem)
return SshKeyPair(
connection_id=connection_id,
public_key=public_key_pem,
encrypted_private_key=encrypted,
status=SshKeyStatus.generated,
)
@staticmethod
def transition(key: SshKeyPair, new_status: SshKeyStatus) -> SshKeyPair:
"""Update *key* status and timestamps.
Sets ``revoked_at`` when transitioning to :attr:`SshKeyStatus.revoked`.
"""
key.status = new_status
key.updated_at = datetime.now(UTC)
if new_status == SshKeyStatus.revoked:
key.revoked_at = datetime.now(UTC)
return key
-38
View File
@@ -1,38 +0,0 @@
"""Enumerations for Git provider abstraction."""
from enum import StrEnum
class ProviderKind(StrEnum):
"""Supported Git provider kinds."""
github = "github"
gitlab = "gitlab"
gitea = "gitea"
forgejo = "forgejo"
generic = "generic"
class CredentialKind(StrEnum):
"""Supported credential kinds for Git authentication."""
ssh_key = "ssh_key"
access_token = "access_token"
class ConnectionStatus(StrEnum):
"""Lifecycle states for a repository connection."""
pending = "pending"
connected = "connected"
disconnected = "disconnected"
error = "error"
class SshKeyStatus(StrEnum):
"""Lifecycle states for an SSH key pair."""
generated = "generated"
registered = "registered"
rotating = "rotating"
revoked = "revoked"
-65
View File
@@ -1,65 +0,0 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.config import settings
from app.db import AsyncSessionLocal, engine
from app.routers import routers
from app.tools.registry import registry
from app.tools.router import router as tools_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
registry.load_builtin_manifests()
async with AsyncSessionLocal() as session:
try:
await session.execute(text("SELECT 1"))
except Exception:
import logging
logging.getLogger(__name__).warning("Database connectivity check failed on startup")
yield
await engine.dispose()
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
lifespan=lifespan,
)
allow_origins = settings.cors_origins.split(",") if settings.cors_origins else []
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
for router in routers:
app.include_router(router, prefix=settings.api_v1_prefix)
app.include_router(tools_router, prefix=settings.api_v1_prefix)
@app.get("/health")
async def health() -> JSONResponse:
db_status = "connected"
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
except Exception:
db_status = "unreachable"
content = {
"status": "ok" if db_status == "connected" else "degraded",
"service": settings.app_name,
"database": db_status,
}
status_code = 200 if db_status == "connected" else 503
return JSONResponse(status_code=status_code, content=content)
-27
View File
@@ -1,27 +0,0 @@
from app.models.access_route import AccessRoute
from app.models.base import Base
from app.models.config import Config
from app.models.credential import Credential
from app.models.project import Project
from app.models.repository import Repository
from app.models.repository_connection import RepositoryConnection
from app.models.secret import Secret
from app.models.tool_definition import ToolDefinition
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.models.workspace import Workspace
__all__ = [
"Base",
"AccessRoute",
"Config",
"Credential",
"Project",
"Repository",
"RepositoryConnection",
"Secret",
"ToolDefinition",
"ToolInstance",
"User",
"Workspace",
]
-35
View File
@@ -1,35 +0,0 @@
import uuid
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.tool_instance import ToolInstance
class AccessRoute(Base, UUIDMixin, TimestampMixin):
__tablename__ = "access_route"
tool_instance_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tool_instance.id"), index=True
)
domain: Mapped[str] = mapped_column(Text)
path_prefix: Mapped[str] = mapped_column(
String(255), default="/"
)
provider_type: Mapped[str] = mapped_column(
String(50), default="traefik"
)
provider_config: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True
)
tool_instance: Mapped["ToolInstance"] = relationship(
back_populates="access_routes"
)
-26
View File
@@ -1,26 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UUIDMixin:
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
default=uuid.uuid4,
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
onupdate=func.now(),
)
-22
View File
@@ -1,22 +0,0 @@
import uuid
from typing import Any
from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class Config(Base, UUIDMixin, TimestampMixin):
__tablename__ = "config"
__table_args__ = (
UniqueConstraint("scope_type", "scope_id", "tool_definition_id", "key"),
)
scope_type: Mapped[str] = mapped_column(String(50))
scope_id: Mapped[uuid.UUID] = mapped_column(index=True)
tool_definition_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tool_definition.id"), nullable=True, index=True
)
key: Mapped[str] = mapped_column(String(255))
value: Mapped[dict[str, Any]] = mapped_column(JSON)
-16
View File
@@ -1,16 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
pass
class Credential(Base, UUIDMixin, TimestampMixin):
__tablename__ = "credential"
kind: Mapped[str] = mapped_column(String(50))
encrypted_payload: Mapped[str] = mapped_column(Text)
-36
View File
@@ -1,36 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.repository import Repository
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.models.workspace import Workspace
class Project(Base, UUIDMixin, TimestampMixin):
__tablename__ = "project"
__table_args__ = (UniqueConstraint("owner_id", "slug"),)
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
owner: Mapped["User"] = relationship(back_populates="projects")
repositories: Mapped[list["Repository"]] = relationship(
back_populates="project"
)
workspaces: Mapped[list["Workspace"]] = relationship(
back_populates="project"
)
tool_instances: Mapped[list["ToolInstance"]] = relationship(
back_populates="project"
)
-34
View File
@@ -1,34 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.project import Project
from app.models.repository_connection import RepositoryConnection
class Repository(Base, UUIDMixin, TimestampMixin):
__tablename__ = "repository"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
git_url: Mapped[str] = mapped_column(Text)
provider_type: Mapped[str] = mapped_column(
String(50), default="generic"
)
default_branch: Mapped[str] = mapped_column(
String(100), default="main"
)
project: Mapped["Project"] = relationship(
back_populates="repositories"
)
connections: Mapped[list["RepositoryConnection"]] = relationship(
back_populates="repository"
)
@@ -1,42 +0,0 @@
"""RepositoryConnection links a project to a Git repository via a provider."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.repository import Repository
class RepositoryConnection(Base, UUIDMixin, TimestampMixin):
__tablename__ = "repository_connection"
# NOTE: A partial unique index on (project_id, repository_id, provider_kind)
# when repository_id IS NOT NULL is deferred for MVP. Duplicate connections
# are acceptable until explicit disambiguation is required.
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
repository_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("repository.id"), nullable=True, index=True
)
provider_kind: Mapped[str] = mapped_column(
String(50), default="generic"
)
credential_id: Mapped[uuid.UUID | None] = mapped_column(
index=True, nullable=True
)
connection_status: Mapped[str] = mapped_column(
String(50), default="pending"
)
default_branch: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
repository: Mapped["Repository"] = relationship(
back_populates="connections"
)
-18
View File
@@ -1,18 +0,0 @@
import uuid
from sqlalchemy import String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class Secret(Base, UUIDMixin, TimestampMixin):
__tablename__ = "secret"
__table_args__ = (
UniqueConstraint("scope_type", "scope_id", "key"),
)
scope_type: Mapped[str] = mapped_column(String(50))
scope_id: Mapped[uuid.UUID] = mapped_column(index=True)
key: Mapped[str] = mapped_column(String(255))
encrypted_value: Mapped[str] = mapped_column(Text)
-32
View File
@@ -1,32 +0,0 @@
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.tool_instance import ToolInstance
class ToolDefinition(Base, UUIDMixin, TimestampMixin):
__tablename__ = "tool_definition"
key: Mapped[str] = mapped_column(
String(100), unique=True, index=True
)
name: Mapped[str] = mapped_column(String(255))
version: Mapped[str] = mapped_column(
String(50), default="1.0.0"
)
description: Mapped[str | None] = mapped_column(
Text, nullable=True
)
image: Mapped[str] = mapped_column(Text)
manifest_data: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
instances: Mapped[list["ToolInstance"]] = relationship(
back_populates="tool_definition"
)
-49
View File
@@ -1,49 +0,0 @@
import uuid
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.access_route import AccessRoute
from app.models.project import Project
from app.models.tool_definition import ToolDefinition
class ToolInstance(Base, UUIDMixin, TimestampMixin):
__tablename__ = "tool_instance"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
tool_definition_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tool_definition.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(
String(50), default="pending"
)
container_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
subdomain: Mapped[str | None] = mapped_column(
String(255), nullable=True, unique=True
)
config_override: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
traefik_labels: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
project: Mapped["Project"] = relationship(
back_populates="tool_instances"
)
tool_definition: Mapped["ToolDefinition"] = relationship(
back_populates="instances"
)
access_routes: Mapped[list["AccessRoute"]] = relationship(
back_populates="tool_instance"
)
-30
View File
@@ -1,30 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.project import Project
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "user"
authentik_sub: Mapped[str] = mapped_column(
String(255), unique=True, index=True
)
email: Mapped[str] = mapped_column(
String(255), unique=True, index=True
)
display_name: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True
)
projects: Mapped[list["Project"]] = relationship(
back_populates="owner"
)
-24
View File
@@ -1,24 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.project import Project
class Workspace(Base, UUIDMixin, TimestampMixin):
__tablename__ = "workspace"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
mount_path: Mapped[str | None] = mapped_column(Text, nullable=True)
project: Mapped["Project"] = relationship(
back_populates="workspaces"
)
-27
View File
@@ -1,27 +0,0 @@
from fastapi import APIRouter
from app.routers.access_routes import router as access_routes_router
from app.routers.configs import router as configs_router
from app.routers.projects import router as projects_router
from app.routers.repositories import router as repositories_router
from app.routers.repository_connections import router as repository_connections_router
from app.routers.secrets import router as secrets_router
from app.routers.tool_definitions import router as tool_definitions_router
from app.routers.tool_instances import router as tool_instances_router
from app.routers.users import router as users_router
from app.routers.workspaces import router as workspaces_router
routers: list[APIRouter] = [
access_routes_router,
configs_router,
projects_router,
repositories_router,
repository_connections_router,
secrets_router,
tool_definitions_router,
tool_instances_router,
users_router,
workspaces_router,
]
__all__ = ["routers"]
-103
View File
@@ -1,103 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.access_route import AccessRoute
from app.models.project import Project
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate
router = APIRouter(tags=["access-routes"])
async def _verify_tool_instance_ownership(
instance_id: UUID, user: User, session: AsyncSession
) -> None:
ti = await session.get(ToolInstance, instance_id)
if not ti:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found")
project = await session.get(Project, ti.project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
@router.post("/tool-instances/{instance_id}/access-routes", response_model=AccessRouteRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_access_route(
instance_id: UUID,
ar_in: AccessRouteCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> AccessRoute:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = AccessRoute(**ar_in.model_dump(), tool_instance_id=instance_id)
session.add(ar)
await session.commit()
await session.refresh(ar)
return ar
@router.get("/tool-instances/{instance_id}/access-routes", response_model=list[AccessRouteRead])
async def list_access_routes(
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[AccessRoute]:
await _verify_tool_instance_ownership(instance_id, current_user, session)
result = await session.execute(
select(AccessRoute).where(AccessRoute.tool_instance_id == instance_id)
)
return list(result.scalars().all())
@router.get("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501
async def get_access_route(
instance_id: UUID,
route_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> AccessRoute:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = await session.get(AccessRoute, route_id)
if not ar or ar.tool_instance_id != instance_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
return ar
@router.put("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501
async def update_access_route(
instance_id: UUID,
route_id: UUID,
ar_in: AccessRouteUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> AccessRoute:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = await session.get(AccessRoute, route_id)
if not ar or ar.tool_instance_id != instance_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
update_data = ar_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ar, field, value)
await session.commit()
await session.refresh(ar)
return ar
@router.delete("/tool-instances/{instance_id}/access-routes/{route_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
async def delete_access_route(
instance_id: UUID,
route_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = await session.get(AccessRoute, route_id)
if not ar or ar.tool_instance_id != instance_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
await session.delete(ar)
await session.commit()
-122
View File
@@ -1,122 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.config import Config
from app.models.project import Project
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate
router = APIRouter(tags=["configs"])
async def _verify_config_ownership(
config_obj: Config, user: User, session: AsyncSession
) -> None:
if config_obj.scope_type == "user":
if config_obj.scope_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif config_obj.scope_type == "project":
project = await session.get(Project, config_obj.scope_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif config_obj.scope_type == "tool_instance":
ti = await session.get(ToolInstance, config_obj.scope_id)
if not ti:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
project = await session.get(Project, ti.project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif config_obj.scope_type == "global":
pass
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type")
@router.post("/configs", response_model=ConfigRead, status_code=status.HTTP_201_CREATED)
async def create_config(
config_in: ConfigCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Config:
cfg = Config(**config_in.model_dump())
await _verify_config_ownership(cfg, current_user, session)
session.add(cfg)
await session.commit()
await session.refresh(cfg)
return cfg
@router.get("/configs", response_model=list[ConfigRead])
async def list_configs(
scope_type: str | None = None,
scope_id: UUID | None = None,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Config]:
stmt = select(Config)
if scope_type:
stmt = stmt.where(Config.scope_type == scope_type)
if scope_id:
stmt = stmt.where(Config.scope_id == scope_id)
result = await session.execute(stmt)
configs = list(result.scalars().all())
allowed = []
for cfg in configs:
try:
await _verify_config_ownership(cfg, current_user, session)
allowed.append(cfg)
except HTTPException:
pass
return allowed
@router.get("/configs/{config_id}", response_model=ConfigRead)
async def get_config(
config_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Config:
cfg = await session.get(Config, config_id)
if not cfg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await _verify_config_ownership(cfg, current_user, session)
return cfg
@router.put("/configs/{config_id}", response_model=ConfigRead)
async def update_config(
config_id: UUID,
config_in: ConfigUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Config:
cfg = await session.get(Config, config_id)
if not cfg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await _verify_config_ownership(cfg, current_user, session)
update_data = config_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(cfg, field, value)
await session.commit()
await session.refresh(cfg)
return cfg
@router.delete("/configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
cfg = await session.get(Config, config_id)
if not cfg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await _verify_config_ownership(cfg, current_user, session)
await session.delete(cfg)
await session.commit()
-80
View File
@@ -1,80 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.project import Project
from app.models.user import User
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
router = APIRouter(tags=["projects"])
@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
async def create_project(
project_in: ProjectCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Project:
project = Project(**project_in.model_dump(), owner_id=current_user.id)
session.add(project)
await session.commit()
await session.refresh(project)
return project
@router.get("/projects", response_model=list[ProjectRead])
async def list_projects(
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
result = await session.execute(
select(Project).where(Project.owner_id == current_user.id)
)
return list(result.scalars().all())
@router.get("/projects/{project_id}", response_model=ProjectRead)
async def get_project(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.put("/projects/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: UUID,
project_in: ProjectUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
update_data = project_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
await session.commit()
await session.refresh(project)
return project
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
project = await session.get(Project, project_id)
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
await session.delete(project)
await session.commit()
-100
View File
@@ -1,100 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.project import Project
from app.models.repository import Repository
from app.models.user import User
from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate
router = APIRouter(tags=["repositories"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.post("/projects/{project_id}/repositories", response_model=RepositoryRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_repository(
project_id: UUID,
repo_in: RepositoryCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Repository:
await _get_project_for_user(project_id, current_user, session)
repo = Repository(**repo_in.model_dump(), project_id=project_id)
session.add(repo)
await session.commit()
await session.refresh(repo)
return repo
@router.get("/projects/{project_id}/repositories", response_model=list[RepositoryRead])
async def list_repositories(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Repository]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(Repository).where(Repository.project_id == project_id)
)
return list(result.scalars().all())
@router.get("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead)
async def get_repository(
project_id: UUID,
repo_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Repository:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, repo_id)
if not repo or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
return repo
@router.put("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead)
async def update_repository(
project_id: UUID,
repo_id: UUID,
repo_in: RepositoryUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Repository:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, repo_id)
if not repo or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
update_data = repo_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
@router.delete("/projects/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
async def delete_repository(
project_id: UUID,
repo_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, repo_id)
if not repo or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
await session.delete(repo)
await session.commit()
@@ -1,243 +0,0 @@
"""Repository connection router."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.git.credential_storage import DatabaseCredentialStorage
from app.git.credentials import AccessTokenCredential, GitCredential
from app.git.providers import get_provider
from app.git.ssh_key import SshKeyLifecycle
from app.git.types import ConnectionStatus, ProviderKind
from app.models.project import Project
from app.models.repository import Repository
from app.models.repository_connection import RepositoryConnection
from app.models.user import User
from app.schemas.repository_connection import (
RepositoryConnectionCreate,
RepositoryConnectionRead,
SshKeyResponse,
)
router = APIRouter(tags=["repository-connections"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
return project
@router.post(
"/projects/{project_id}/repository-connections",
response_model=RepositoryConnectionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_repository_connection(
project_id: UUID,
conn_in: RepositoryConnectionCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, conn_in.repository_id)
if not repo or repo.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
)
result = await session.execute(
select(RepositoryConnection).where(
RepositoryConnection.project_id == project_id,
RepositoryConnection.repository_id == conn_in.repository_id,
RepositoryConnection.provider_kind == conn_in.provider_kind,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Connection already exists for this repository and provider",
)
storage = DatabaseCredentialStorage(session)
credential: GitCredential
if conn_in.credential_kind == "access_token":
credential = AccessTokenCredential(
encrypted_payload=conn_in.credential_payload
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported credential kind: {conn_in.credential_kind}",
)
credential_id = await storage.create(credential)
connection = RepositoryConnection(
project_id=project_id,
repository_id=conn_in.repository_id,
provider_kind=conn_in.provider_kind,
credential_id=credential_id,
connection_status=str(ConnectionStatus.pending),
)
session.add(connection)
await session.commit()
await session.refresh(connection)
try:
provider = get_provider(ProviderKind(conn_in.provider_kind))
provider_status = provider.validate_connection(repo.git_url, str(credential_id))
connection.connection_status = str(provider_status)
except Exception:
connection.connection_status = str(ConnectionStatus.error)
await session.commit()
await session.refresh(connection)
return connection
@router.get(
"/projects/{project_id}/repository-connections",
response_model=list[RepositoryConnectionRead],
)
async def list_repository_connections(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[RepositoryConnection]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(RepositoryConnection).where(
RepositoryConnection.project_id == project_id
)
)
return list(result.scalars().all())
@router.get(
"/projects/{project_id}/repository-connections/{connection_id}",
response_model=RepositoryConnectionRead,
)
async def get_repository_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
return connection
@router.delete(
"/projects/{project_id}/repository-connections/{connection_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_repository_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
if connection.credential_id:
storage = DatabaseCredentialStorage(session)
await storage.delete(connection.credential_id)
await session.delete(connection)
await session.commit()
@router.post(
"/projects/{project_id}/repository-connections/{connection_id}/ssh-key",
response_model=SshKeyResponse,
status_code=status.HTTP_201_CREATED,
)
async def generate_ssh_key(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
key_pair = SshKeyLifecycle.generate(connection_id)
storage = DatabaseCredentialStorage(session)
ssh_credential = GitCredential(
kind="ssh_key",
encrypted_payload=key_pair.encrypted_private_key,
)
credential_id = await storage.create(ssh_credential)
connection.credential_id = credential_id
await session.commit()
return {
"connection_id": str(connection_id),
"public_key": key_pair.public_key,
"credential_id": str(credential_id),
}
@router.post(
"/projects/{project_id}/repository-connections/{connection_id}/validate",
response_model=RepositoryConnectionRead,
)
async def validate_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
repo = await session.get(Repository, connection.repository_id)
if not repo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
)
try:
provider = get_provider(ProviderKind(connection.provider_kind))
provider_status = provider.validate_connection(
repo.git_url, str(connection.credential_id) if connection.credential_id else ""
)
connection.connection_status = str(provider_status)
except Exception:
connection.connection_status = str(ConnectionStatus.error)
await session.commit()
await session.refresh(connection)
return connection
-129
View File
@@ -1,129 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.encryption import encrypt_value
from app.models.project import Project
from app.models.secret import Secret
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate
router = APIRouter(tags=["secrets"])
async def _verify_secret_ownership(
secret_obj: Secret, user: User, session: AsyncSession
) -> None:
if secret_obj.scope_type == "user":
if secret_obj.scope_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif secret_obj.scope_type == "project":
project = await session.get(Project, secret_obj.scope_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif secret_obj.scope_type == "tool_instance":
ti = await session.get(ToolInstance, secret_obj.scope_id)
if not ti:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
project = await session.get(Project, ti.project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif secret_obj.scope_type == "global":
pass
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type")
@router.post("/secrets", response_model=SecretRead, status_code=status.HTTP_201_CREATED)
async def create_secret(
secret_in: SecretCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> SecretRead:
secret = Secret(
scope_type=secret_in.scope_type,
scope_id=secret_in.scope_id,
key=secret_in.key,
encrypted_value=encrypt_value(secret_in.value),
)
await _verify_secret_ownership(secret, current_user, session)
session.add(secret)
await session.commit()
await session.refresh(secret)
return SecretRead.from_secret(secret)
@router.get("/secrets", response_model=list[SecretRead])
async def list_secrets(
scope_type: str | None = None,
scope_id: UUID | None = None,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[SecretRead]:
stmt = select(Secret)
if scope_type:
stmt = stmt.where(Secret.scope_type == scope_type)
if scope_id:
stmt = stmt.where(Secret.scope_id == scope_id)
result = await session.execute(stmt)
secrets = list(result.scalars().all())
allowed = []
for s in secrets:
try:
await _verify_secret_ownership(s, current_user, session)
allowed.append(SecretRead.from_secret(s))
except HTTPException:
pass
return allowed
@router.get("/secrets/{secret_id}", response_model=SecretRead)
async def get_secret(
secret_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> SecretRead:
s = await session.get(Secret, secret_id)
if not s:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
await _verify_secret_ownership(s, current_user, session)
return SecretRead.from_secret(s)
@router.put("/secrets/{secret_id}", response_model=SecretRead)
async def update_secret(
secret_id: UUID,
secret_in: SecretUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> SecretRead:
s = await session.get(Secret, secret_id)
if not s:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
await _verify_secret_ownership(s, current_user, session)
if secret_in.key is not None:
s.key = secret_in.key
if secret_in.value is not None:
s.encrypted_value = encrypt_value(secret_in.value)
await session.commit()
await session.refresh(s)
return SecretRead.from_secret(s)
@router.delete("/secrets/{secret_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_secret(
secret_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
s = await session.get(Secret, secret_id)
if not s:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
await _verify_secret_ownership(s, current_user, session)
await session.delete(s)
await session.commit()
-88
View File
@@ -1,88 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.tool_definition import ToolDefinition
from app.models.user import User
from app.schemas.tool_definition import (
ToolDefinitionCreate,
ToolDefinitionRead,
ToolDefinitionUpdate,
)
router = APIRouter(tags=["tool-definitions"])
@router.post("/tool-definitions", response_model=ToolDefinitionRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_tool_definition(
td_in: ToolDefinitionCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolDefinition:
td = ToolDefinition(**td_in.model_dump())
session.add(td)
await session.commit()
await session.refresh(td)
return td
@router.get("/tool-definitions", response_model=list[ToolDefinitionRead])
async def list_tool_definitions(
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolDefinition]:
result = await session.execute(select(ToolDefinition))
return list(result.scalars().all())
@router.get("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead)
async def get_tool_definition(
tool_def_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolDefinition:
td = await session.get(ToolDefinition, tool_def_id)
if not td:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
)
return td
@router.put("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead)
async def update_tool_definition(
tool_def_id: UUID,
td_in: ToolDefinitionUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolDefinition:
td = await session.get(ToolDefinition, tool_def_id)
if not td:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
)
update_data = td_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(td, field, value)
await session.commit()
await session.refresh(td)
return td
@router.delete("/tool-definitions/{tool_def_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tool_definition(
tool_def_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
td = await session.get(ToolDefinition, tool_def_id)
if not td:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
)
await session.delete(td)
await session.commit()
-327
View File
@@ -1,327 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.config import settings
from app.db import get_db_session
from app.models.project import Project
from app.models.tool_definition import ToolDefinition
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
from app.services.spawn import SpawnError, SpawnService
from app.services.traefik import TraefikLabelGenerator
from app.tools.registry import registry
router = APIRouter(tags=["tool-instances"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
def _get_user_slug(user: User) -> str:
user_slug = (
user.display_name
or user.email.split("@")[0]
if user.email
else "user"
)
return user_slug.lower().replace(" ", "-").replace("_", "-")
@router.post(
"/projects/{project_id}/tool-instances",
response_model=ToolInstanceRead,
status_code=status.HTTP_201_CREATED,
)
async def create_tool_instance(
project_id: UUID,
ti_in: ToolInstanceCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
project = await _get_project_for_user(project_id, current_user, session)
tool_def = await session.get(ToolDefinition, ti_in.tool_definition_id)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool definition not found",
)
manifest = registry.get(tool_def.key)
if not manifest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool manifest '{tool_def.key}' not found in registry",
)
existing = await session.execute(
select(ToolInstance).where(
ToolInstance.project_id == project_id,
ToolInstance.tool_definition_id == ti_in.tool_definition_id,
ToolInstance.status.in_(["creating", "running"]),
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A running instance of this tool already exists for this project",
)
ti = ToolInstance(**ti_in.model_dump(), project_id=project_id)
user_slug = _get_user_slug(current_user)
spawn_service = SpawnService()
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
try:
spawn_result = spawn_service.spawn(
instance_id=str(ti.id),
manifest=manifest,
project_slug=project.slug,
user_slug=user_slug,
)
except SpawnError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to spawn container: {e}",
) from e
auth_labels = label_gen.generate_forward_auth_labels(
instance_id=str(ti.id),
auth_url=f"https://{settings.root_domain}/api/v1/auth/validate",
)
traefik_labels = {**spawn_result["traefik_labels"], **auth_labels}
ti.container_id = spawn_result["container_id"]
ti.subdomain = spawn_result["subdomain"]
ti.traefik_labels = traefik_labels
ti.status = spawn_service.get_status(str(ti.id))
session.add(ti)
await session.commit()
await session.refresh(ti)
return ti
@router.get(
"/projects/{project_id}/tool-instances",
response_model=list[ToolInstanceRead],
)
async def list_tool_instances(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolInstance]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(ToolInstance).where(ToolInstance.project_id == project_id)
)
return list(result.scalars().all())
@router.get(
"/projects/{project_id}/tool-instances/{instance_id}",
response_model=ToolInstanceRead,
)
async def get_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
return ti
@router.put(
"/projects/{project_id}/tool-instances/{instance_id}",
response_model=ToolInstanceRead,
)
async def update_tool_instance(
project_id: UUID,
instance_id: UUID,
ti_in: ToolInstanceUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
update_data = ti_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ti, field, value)
await session.commit()
await session.refresh(ti)
return ti
@router.delete(
"/projects/{project_id}/tool-instances/{instance_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
spawn_service.stop(str(instance_id))
await session.delete(ti)
await session.commit()
@router.post(
"/projects/{project_id}/tool-instances/{instance_id}/stop",
response_model=ToolInstanceRead,
)
async def stop_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
spawn_service.stop(str(instance_id))
ti.status = "stopped"
ti.container_id = None
await session.commit()
await session.refresh(ti)
return ti
@router.post(
"/projects/{project_id}/tool-instances/{instance_id}/start",
response_model=ToolInstanceRead,
)
async def start_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
tool_def = await session.get(ToolDefinition, ti.tool_definition_id)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool definition not found",
)
manifest = registry.get(tool_def.key)
if not manifest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool manifest '{tool_def.key}' not found in registry",
)
user_slug = _get_user_slug(current_user)
spawn_service = SpawnService()
try:
spawn_result = spawn_service.spawn(
instance_id=str(ti.id),
manifest=manifest,
project_slug=ti.project.slug,
user_slug=user_slug,
)
except SpawnError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to spawn container: {e}",
) from e
ti.container_id = spawn_result["container_id"]
ti.subdomain = spawn_result["subdomain"]
ti.traefik_labels = spawn_result["traefik_labels"]
ti.status = spawn_service.get_status(str(ti.id))
await session.commit()
await session.refresh(ti)
return ti
@router.get(
"/projects/{project_id}/tool-instances/{instance_id}/status",
response_model=dict,
)
async def get_tool_instance_status(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
container_status = spawn_service.get_status(str(instance_id))
if ti.status != container_status:
ti.status = container_status
await session.commit()
return {
"instance_id": str(instance_id),
"status": container_status,
"subdomain": ti.subdomain or "",
"container_id": ti.container_id or "",
}
@router.get("/auth/validate", status_code=status.HTTP_200_OK)
async def validate_auth_for_traefik(
current_user: User = Depends(get_current_active_user),
) -> dict[str, str]:
return {"status": "ok", "user_id": str(current_user.id)}
-17
View File
@@ -1,17 +0,0 @@
from fastapi import APIRouter, Depends
from app.auth.dependencies import get_current_active_user
from app.models.user import User
from app.schemas.user import UserRead
router = APIRouter(tags=["users"])
@router.get("/users/me", response_model=UserRead)
async def read_current_user(current_user: User = Depends(get_current_active_user)) -> User:
return current_user
@router.get("/users", response_model=list[UserRead])
async def list_users(current_user: User = Depends(get_current_active_user)) -> list[User]:
return [current_user]
-100
View File
@@ -1,100 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.project import Project
from app.models.user import User
from app.models.workspace import Workspace
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
router = APIRouter(tags=["workspaces"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.post("/projects/{project_id}/workspaces", response_model=WorkspaceRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_workspace(
project_id: UUID,
ws_in: WorkspaceCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Workspace:
await _get_project_for_user(project_id, current_user, session)
ws = Workspace(**ws_in.model_dump(), project_id=project_id)
session.add(ws)
await session.commit()
await session.refresh(ws)
return ws
@router.get("/projects/{project_id}/workspaces", response_model=list[WorkspaceRead])
async def list_workspaces(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Workspace]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(Workspace).where(Workspace.project_id == project_id)
)
return list(result.scalars().all())
@router.get("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead)
async def get_workspace(
project_id: UUID,
ws_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Workspace:
await _get_project_for_user(project_id, current_user, session)
ws = await session.get(Workspace, ws_id)
if not ws or ws.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
return ws
@router.put("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead)
async def update_workspace(
project_id: UUID,
ws_id: UUID,
ws_in: WorkspaceUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Workspace:
await _get_project_for_user(project_id, current_user, session)
ws = await session.get(Workspace, ws_id)
if not ws or ws.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
update_data = ws_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ws, field, value)
await session.commit()
await session.refresh(ws)
return ws
@router.delete("/projects/{project_id}/workspaces/{ws_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
project_id: UUID,
ws_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
ws = await session.get(Workspace, ws_id)
if not ws or ws.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
await session.delete(ws)
await session.commit()
-42
View File
@@ -1,42 +0,0 @@
from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate
from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate
from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate
from app.schemas.tool_definition import (
ToolDefinitionCreate,
ToolDefinitionRead,
ToolDefinitionUpdate,
)
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
from app.schemas.user import UserCreate, UserRead
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
__all__ = [
"AccessRouteCreate",
"AccessRouteRead",
"AccessRouteUpdate",
"ConfigCreate",
"ConfigRead",
"ConfigUpdate",
"ProjectCreate",
"ProjectRead",
"ProjectUpdate",
"RepositoryCreate",
"RepositoryRead",
"RepositoryUpdate",
"SecretCreate",
"SecretRead",
"SecretUpdate",
"ToolDefinitionCreate",
"ToolDefinitionRead",
"ToolDefinitionUpdate",
"ToolInstanceCreate",
"ToolInstanceRead",
"ToolInstanceUpdate",
"UserCreate",
"UserRead",
"WorkspaceCreate",
"WorkspaceRead",
"WorkspaceUpdate",
]
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class AccessRouteBase(OrmBase):
domain: str
path_prefix: str = "/"
provider_type: str = "traefik"
provider_config: dict[str, Any] | None = None
is_active: bool = True
class AccessRouteCreate(AccessRouteBase):
pass
class AccessRouteRead(AccessRouteBase):
id: UUID
tool_instance_id: UUID
class AccessRouteUpdate(OrmBase):
domain: str | None = None
path_prefix: str | None = None
provider_type: str | None = None
provider_config: dict[str, Any] | None = None
is_active: bool | None = None
-5
View File
@@ -1,5 +0,0 @@
from pydantic import BaseModel, ConfigDict
class OrmBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
-25
View File
@@ -1,25 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class ConfigBase(OrmBase):
scope_type: str
scope_id: UUID
tool_definition_id: UUID | None = None
key: str
value: dict[str, Any]
class ConfigCreate(ConfigBase):
pass
class ConfigRead(ConfigBase):
id: UUID
class ConfigUpdate(OrmBase):
key: str | None = None
value: dict[str, Any] | None = None
-24
View File
@@ -1,24 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class ProjectBase(OrmBase):
name: str
slug: str
description: str | None = None
class ProjectCreate(ProjectBase):
pass
class ProjectRead(ProjectBase):
id: UUID
owner_id: UUID
class ProjectUpdate(OrmBase):
name: str | None = None
slug: str | None = None
description: str | None = None
-26
View File
@@ -1,26 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class RepositoryBase(OrmBase):
name: str
git_url: str
provider_type: str = "generic"
default_branch: str = "main"
class RepositoryCreate(RepositoryBase):
pass
class RepositoryRead(RepositoryBase):
id: UUID
project_id: UUID
class RepositoryUpdate(OrmBase):
name: str | None = None
git_url: str | None = None
provider_type: str | None = None
default_branch: str | None = None
@@ -1,35 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class RepositoryConnectionBase(OrmBase):
project_id: UUID
repository_id: UUID | None = None
provider_kind: str = "generic"
credential_id: UUID | None = None
connection_status: str = "pending"
default_branch: str | None = None
class RepositoryConnectionCreate(OrmBase):
repository_id: UUID
provider_kind: str
credential_kind: str
credential_payload: str
class RepositoryConnectionRead(OrmBase):
id: UUID
project_id: UUID
repository_id: UUID | None = None
provider_kind: str
credential_id: UUID | None = None
connection_status: str
default_branch: str | None = None
class SshKeyResponse(OrmBase):
connection_id: UUID
public_key: str
credential_id: UUID
-39
View File
@@ -1,39 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from app.schemas.base import OrmBase
if TYPE_CHECKING:
from app.models.secret import Secret
class SecretBase(OrmBase):
scope_type: str
scope_id: UUID
key: str
class SecretCreate(SecretBase):
value: str
class SecretRead(SecretBase):
id: UUID
value: str = "••••••"
@classmethod
def from_secret(cls, secret: Secret) -> SecretRead:
return cls(
id=secret.id,
scope_type=secret.scope_type,
scope_id=secret.scope_id,
key=secret.key,
value="••••••",
)
class SecretUpdate(OrmBase):
key: str | None = None
value: str | None = None
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class ToolDefinitionBase(OrmBase):
key: str
name: str
version: str = "1.0.0"
description: str | None = None
image: str
manifest_data: dict[str, Any] | None = None
class ToolDefinitionCreate(ToolDefinitionBase):
pass
class ToolDefinitionRead(ToolDefinitionBase):
id: UUID
class ToolDefinitionUpdate(OrmBase):
name: str | None = None
version: str | None = None
description: str | None = None
image: str | None = None
manifest_data: dict[str, Any] | None = None
-32
View File
@@ -1,32 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class ToolInstanceBase(OrmBase):
name: str
status: str = "pending"
container_id: str | None = None
subdomain: str | None = None
config_override: dict[str, Any] | None = None
traefik_labels: dict[str, Any] | None = None
class ToolInstanceCreate(ToolInstanceBase):
tool_definition_id: UUID
class ToolInstanceRead(ToolInstanceBase):
id: UUID
project_id: UUID
tool_definition_id: UUID
class ToolInstanceUpdate(OrmBase):
name: str | None = None
status: str | None = None
container_id: str | None = None
subdomain: str | None = None
config_override: dict[str, Any] | None = None
traefik_labels: dict[str, Any] | None = None
-21
View File
@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import UUID
from app.schemas.base import OrmBase
class UserBase(OrmBase):
authentik_sub: str
email: str
display_name: str | None = None
is_active: bool = True
class UserCreate(UserBase):
pass
class UserRead(UserBase):
id: UUID
created_at: datetime
updated_at: datetime
-22
View File
@@ -1,22 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class WorkspaceBase(OrmBase):
name: str
mount_path: str | None = None
class WorkspaceCreate(WorkspaceBase):
pass
class WorkspaceRead(WorkspaceBase):
id: UUID
project_id: UUID
class WorkspaceUpdate(OrmBase):
name: str | None = None
mount_path: str | None = None
-135
View File
@@ -1,135 +0,0 @@
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.encryption import decrypt_value
from app.models.config import Config
from app.models.secret import Secret
class RuntimeInjectionError(Exception):
pass
class RuntimeInjectionService:
SCOPE_HIERARCHY = ["global", "user", "project", "tool_instance"]
@staticmethod
async def resolve_configs(
session: AsyncSession,
project_id: uuid.UUID,
user_id: uuid.UUID,
instance_id: uuid.UUID | None = None,
tool_definition_id: uuid.UUID | None = None,
) -> dict[str, Any]:
stmt = select(Config).where(
(Config.scope_type == "global")
| (
(Config.scope_type == "user")
& (Config.scope_id == user_id)
)
| (
(Config.scope_type == "project")
& (Config.scope_id == project_id)
)
| (
(Config.scope_type == "tool_instance")
& (Config.scope_id == (instance_id or uuid.UUID(int=0)))
)
)
if tool_definition_id:
stmt = stmt.where(
(Config.tool_definition_id == tool_definition_id)
| (Config.tool_definition_id.is_(None))
)
result = await session.execute(stmt)
configs = list(result.scalars().all())
resolved: dict[str, Any] = {}
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
for cfg in configs:
if cfg.scope_type == scope:
resolved[cfg.key] = cfg.value
return resolved
@staticmethod
async def resolve_secrets(
session: AsyncSession,
project_id: uuid.UUID,
user_id: uuid.UUID,
instance_id: uuid.UUID | None = None,
) -> dict[str, str]:
stmt = select(Secret).where(
(Secret.scope_type == "global")
| (
(Secret.scope_type == "user")
& (Secret.scope_id == user_id)
)
| (
(Secret.scope_type == "project")
& (Secret.scope_id == project_id)
)
| (
(Secret.scope_type == "tool_instance")
& (Secret.scope_id == (instance_id or uuid.UUID(int=0)))
)
)
result = await session.execute(stmt)
secrets = list(result.scalars().all())
resolved: dict[str, str] = {}
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
for secret in secrets:
if secret.scope_type == scope:
resolved[secret.key] = decrypt_value(secret.encrypted_value)
return resolved
@staticmethod
def generate_config_files(configs: dict[str, Any], config_dir: Path) -> list[str]:
config_dir.mkdir(parents=True, exist_ok=True)
mounts = []
for key, value in configs.items():
file_path = config_dir / f"{key}.json"
file_path.write_text(json.dumps(value, indent=2))
file_path.chmod(0o400)
mounts.append(f"{file_path}:/app/config/{key}.json:ro")
return mounts
@staticmethod
def generate_secret_env_vars(secrets: dict[str, str]) -> dict[str, str]:
return {key.upper(): value for key, value in secrets.items()}
@staticmethod
async def validate_secrets_exist(
session: AsyncSession,
required_secret_keys: list[str],
project_id: uuid.UUID,
user_id: uuid.UUID,
instance_id: uuid.UUID | None = None,
) -> None:
resolved = await RuntimeInjectionService.resolve_secrets(
session, project_id, user_id, instance_id
)
missing = [key for key in required_secret_keys if key not in resolved]
if missing:
raise RuntimeInjectionError(
f"Missing required secrets: {', '.join(missing)}"
)
-345
View File
@@ -1,345 +0,0 @@
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
from typing import Any
from app.config import settings
from app.services.traefik import TraefikLabelGenerator
from app.tools.models import ToolManifest
logger = logging.getLogger(__name__)
class SpawnError(Exception):
pass
class SpawnService:
def __init__(
self,
compose_dir: Path | None = None,
network_name: str = "tools",
) -> None:
self.compose_dir = compose_dir or Path("/tmp/headquarter-compose")
self.network_name = network_name
self.compose_dir.mkdir(parents=True, exist_ok=True)
def _generate_compose_service(
self,
instance_id: str,
manifest: ToolManifest,
subdomain: str,
traefik_labels: dict[str, str],
project_slug: str,
user_slug: str,
workspace_path: Path | None = None,
config_path: Path | None = None,
ssh_key_path: Path | None = None,
config_mounts: list[str] | None = None,
secret_env_vars: dict[str, str] | None = None,
) -> dict[str, Any]:
service_name = f"tool-{instance_id[:8]}"
service: dict[str, Any] = {
"image": manifest.image,
"container_name": service_name,
"restart": "unless-stopped",
"labels": traefik_labels,
"networks": [self.network_name],
}
if manifest.runtime_command:
service["command"] = manifest.runtime_command
if manifest.runtime_entrypoint:
service["entrypoint"] = manifest.runtime_entrypoint
if manifest.runtime_user:
service["user"] = manifest.runtime_user
if manifest.runtime_working_dir:
service["working_dir"] = manifest.runtime_working_dir
ports = manifest.ports
if ports:
service["ports"] = [
f"{port.container_port}:{port.container_port}"
for port in ports
]
env = dict(manifest.env)
env.update({
"PROJECT_SLUG": project_slug,
"USER_SLUG": user_slug,
})
service["environment"] = env
volumes: list[str] = []
default_workspace = f"/data/workspaces/{user_slug}/{project_slug}"
for mount in manifest.workspace_mounts:
source = mount.source_pattern.format(
project_repo=str(workspace_path) if workspace_path else default_workspace,
)
ro_suffix = ":ro" if mount.read_only else ""
volumes.append(f"{source}:{mount.target}{ro_suffix}")
default_config = f"/data/configs/{user_slug}"
for mount in manifest.config_mounts:
source = mount.source_pattern.format(
user_config=str(config_path) if config_path else default_config,
)
ro_suffix = ":ro" if mount.read_only else ""
volumes.append(f"{source}:{mount.target}{ro_suffix}")
if ssh_key_path and ssh_key_path.exists():
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
if config_mounts:
volumes.extend(config_mounts)
if volumes:
service["volumes"] = volumes
if secret_env_vars:
service["environment"].update(secret_env_vars)
if manifest.health_check:
hc = manifest.health_check
healthcheck: dict[str, Any] = {
"interval": f"{hc.interval_seconds}s",
"timeout": f"{hc.timeout_seconds}s",
"retries": hc.retries,
"start_period": f"{hc.start_period_seconds}s",
}
if hc.type == "http":
healthcheck["test"] = [
"CMD",
"curl",
"-f",
f"http://localhost:{hc.port}{hc.path}",
]
elif hc.type == "tcp":
healthcheck["test"] = [
"CMD",
"nc",
"-z",
"localhost",
str(hc.port),
]
elif hc.type == "command":
healthcheck["test"] = ["CMD"] + (hc.command or [])
service["healthcheck"] = healthcheck
if manifest.resource_limits:
rl = manifest.resource_limits
deploy: dict[str, Any] = {"resources": {"limits": {}}}
if rl.cpus:
deploy["resources"]["limits"]["cpus"] = str(rl.cpus)
if rl.memory_mb:
deploy["resources"]["limits"]["memory"] = f"{rl.memory_mb}M"
if rl.memory_swap_mb is not None and rl.memory_swap_mb >= 0:
deploy["resources"]["limits"]["swap"] = f"{rl.memory_swap_mb}M"
service["deploy"] = deploy
return service
def _write_compose_file(
self,
instance_id: str,
service: dict[str, Any],
) -> Path:
compose_path = self.compose_dir / f"{instance_id}.yml"
compose = {
"version": "3.8",
"services": {f"tool-{instance_id[:8]}": service},
"networks": {
self.network_name: {
"external": True,
},
},
}
compose_path.write_text(json.dumps(compose, indent=2))
return compose_path
def spawn(
self,
instance_id: str,
manifest: ToolManifest,
project_slug: str,
user_slug: str,
workspace_path: Path | None = None,
config_path: Path | None = None,
ssh_key_path: Path | None = None,
config_mounts: list[str] | None = None,
secret_env_vars: dict[str, str] | None = None,
) -> dict[str, Any]:
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
primary_port = next(
(p.container_port for p in manifest.ports if p.primary),
manifest.ports[0].container_port if manifest.ports else 8080,
)
subdomain = label_gen.generate_subdomain(
tool_key=manifest.id,
project_slug=project_slug,
user_slug=user_slug,
)
traefik_labels = label_gen.generate_labels(
instance_id=instance_id,
tool_key=manifest.id,
project_slug=project_slug,
user_slug=user_slug,
container_port=primary_port,
network_name=self.network_name,
)
service = self._generate_compose_service(
instance_id=instance_id,
manifest=manifest,
subdomain=subdomain,
traefik_labels=traefik_labels,
project_slug=project_slug,
user_slug=user_slug,
workspace_path=workspace_path,
config_path=config_path,
ssh_key_path=ssh_key_path,
config_mounts=config_mounts,
secret_env_vars=secret_env_vars,
)
compose_path = self._write_compose_file(instance_id, service)
try:
result = subprocess.run(
[
"docker", "compose",
"-f", str(compose_path),
"-p", f"hq-tool-{instance_id[:8]}",
"up", "-d", "--remove-orphans",
],
capture_output=True,
text=True,
check=True,
)
logger.info("Spawned container for instance %s: %s", instance_id, result.stdout)
except subprocess.CalledProcessError as e:
logger.error("Failed to spawn container for instance %s: %s", instance_id, e.stderr)
raise SpawnError(f"Failed to spawn container: {e.stderr}") from e
container_id = self._get_container_id(instance_id)
return {
"container_id": container_id,
"subdomain": subdomain,
"traefik_labels": traefik_labels,
"compose_path": str(compose_path),
}
def stop(self, instance_id: str) -> None:
compose_path = self.compose_dir / f"{instance_id}.yml"
if not compose_path.exists():
logger.warning("Compose file not found for instance %s", instance_id)
return
try:
subprocess.run(
[
"docker", "compose",
"-f", str(compose_path),
"-p", f"hq-tool-{instance_id[:8]}",
"down",
],
capture_output=True,
text=True,
check=True,
)
logger.info("Stopped container for instance %s", instance_id)
except subprocess.CalledProcessError as e:
logger.error("Failed to stop container for instance %s: %s", instance_id, e.stderr)
raise SpawnError(f"Failed to stop container: {e.stderr}") from e
def get_status(self, instance_id: str) -> str:
container_id = self._get_container_id(instance_id)
if not container_id:
return "stopped"
try:
result = subprocess.run(
[
"docker", "inspect",
"-f", "{{.State.Status}}",
container_id,
],
capture_output=True,
text=True,
check=True,
)
status = result.stdout.strip()
if status == "running":
health = self._get_health_status(container_id)
if health == "healthy":
return "running"
elif health == "unhealthy":
return "error"
else:
return "creating"
elif status in ("exited", "dead"):
return "stopped"
elif status == "paused":
return "stopped"
else:
return "creating"
except subprocess.CalledProcessError:
return "stopped"
def _get_container_id(self, instance_id: str) -> str | None:
service_name = f"tool-{instance_id[:8]}"
project_name = f"hq-tool-{instance_id[:8]}"
try:
result = subprocess.run(
[
"docker", "compose",
"-p", project_name,
"ps", "-q", service_name,
],
capture_output=True,
text=True,
check=True,
)
container_id = result.stdout.strip()
return container_id if container_id else None
except subprocess.CalledProcessError:
return None
def _get_health_status(self, container_id: str) -> str | None:
try:
result = subprocess.run(
[
"docker", "inspect",
"-f", "{{.State.Health.Status}}",
container_id,
],
capture_output=True,
text=True,
check=True,
)
status = result.stdout.strip()
return status if status else None
except subprocess.CalledProcessError:
return None
-101
View File
@@ -1,101 +0,0 @@
class TraefikLabelGenerator:
def __init__(self, domain: str, entrypoint: str = "websecure"):
self.domain = domain
self.entrypoint = entrypoint
def generate_subdomain(
self,
tool_key: str,
project_slug: str,
user_slug: str,
) -> str:
return f"{tool_key}-{project_slug}-{user_slug}.{self.domain}"
def generate_labels(
self,
instance_id: str,
tool_key: str,
project_slug: str,
user_slug: str,
container_port: int,
network_name: str = "tools",
) -> dict[str, str]:
subdomain = self.generate_subdomain(tool_key, project_slug, user_slug)
router_name = f"tool-{instance_id[:8]}"
service_name = f"tool-{instance_id[:8]}"
labels: dict[str, str] = {}
labels["traefik.enable"] = "true"
labels[f"traefik.http.routers.{router_name}.rule"] = (
f"Host(`{subdomain}`)"
)
labels[f"traefik.http.routers.{router_name}.entrypoints"] = (
self.entrypoint
)
labels[f"traefik.http.routers.{router_name}.service"] = service_name
if self.entrypoint == "websecure":
labels[f"traefik.http.routers.{router_name}.tls"] = "true"
labels[
f"traefik.http.routers.{router_name}.tls.certresolver"
] = "letsencrypt"
labels[f"traefik.http.services.{service_name}.loadbalancer.server.port"] = (
str(container_port)
)
labels[f"traefik.http.services.{service_name}.loadbalancer.server.scheme"] = (
"http"
)
middleware_name = f"tool-{instance_id[:8]}-sec"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"
] = "31536000"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
] = "SAMEORIGIN"
labels[f"traefik.http.routers.{router_name}.middlewares"] = middleware_name
labels["traefik.docker.network"] = network_name
return labels
def generate_forward_auth_labels(
self,
instance_id: str,
auth_url: str,
) -> dict[str, str]:
router_name = f"tool-{instance_id[:8]}"
middleware_name = f"tool-{instance_id[:8]}-auth"
return {
f"traefik.http.middlewares.{middleware_name}.forwardauth.address": auth_url,
f"traefik.http.middlewares.{middleware_name}.forwardauth.trustForwardHeader": "true",
f"traefik.http.routers.{router_name}.middlewares": middleware_name,
}
def generate_removal_labels(
self,
instance_id: str,
) -> dict[str, str]:
router_name = f"tool-{instance_id[:8]}"
return {
"traefik.enable": "false",
f"traefik.http.routers.{router_name}.rule": "",
}
View File
@@ -1,52 +0,0 @@
id: code-server
name: code-server
description: VS Code in the browser.
version: "1.0.0"
image: codercom/code-server:latest
runtime_command:
- "--bind-addr"
- "0.0.0.0:8080"
- "--auth"
- "none"
- "--disable-telemetry"
- "--disable-update-check"
runtime_entrypoint: []
runtime_user: "coder"
runtime_working_dir: /workspace
ports:
- container_port: 8080
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/code-server"
target: /home/coder/.config/code-server
read_only: false
env:
PASSWORD: ""
SUDO_PASSWORD: ""
secrets: []
health_check:
type: http
path: /healthz
port: 8080
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 5
resource_limits:
cpus: 2.0
memory_mb: 4096
memory_swap_mb: -1
traefik:
enabled: true
subdomain_prefix: code
port: 8080
middlewares: []
strip_prefix: false
-42
View File
@@ -1,42 +0,0 @@
id: opencode
name: OpenCode
description: AI-powered terminal-based development environment with web interface.
version: "1.0.0"
image: ghcr.io/opencode-ai/opencode:latest
runtime_working_dir: /workspace
ports:
- container_port: 3000
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/opencode"
target: /root/.config/opencode
read_only: false
env:
TERM: xterm-256color
FORCE_COLOR: "1"
health_check:
type: http
path: /
port: 3000
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 15
resource_limits:
cpus: 2.0
memory_mb: 4096
memory_swap_mb: -1
traefik:
enabled: true
subdomain_prefix: opencode
port: 3000
middlewares: []
strip_prefix: false
-109
View File
@@ -1,109 +0,0 @@
"""Pydantic v2 models for the Headquarter tool manifest schema."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
class PortConfig(BaseModel):
container_port: int = Field(..., ge=1, le=65535)
protocol: Literal["tcp", "udp"] = "tcp"
name: str | None = None
primary: bool = False
class MountConfig(BaseModel):
type: Literal["volume", "bind"] = "volume"
source_pattern: str
target: str
read_only: bool = False
@field_validator("target")
@classmethod
def _target_must_be_absolute(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("mount target must be an absolute path")
return v
class SecretRef(BaseModel):
name: str
env_var: str
required: bool = True
class HealthCheckConfig(BaseModel):
type: Literal["http", "tcp", "command"] = "http"
path: str | None = None
command: list[str] | None = None
port: int | None = None
interval_seconds: int = Field(default=10, ge=1)
timeout_seconds: int = Field(default=5, ge=1)
retries: int = Field(default=3, ge=1)
start_period_seconds: int = Field(default=5, ge=0)
@model_validator(mode="after")
def _check_required_fields(self) -> HealthCheckConfig:
if self.type == "http" and not self.path:
raise ValueError('path is required when health_check.type is "http"')
if self.type == "command" and not self.command:
raise ValueError('command is required when health_check.type is "command"')
return self
class ResourceLimits(BaseModel):
cpus: float | None = Field(None, ge=0.01)
memory_mb: int | None = Field(None, ge=16)
memory_swap_mb: int | None = Field(None, ge=-1)
class ExecutableConfig(BaseModel):
node_version: str | None = None
npm_version: str | None = None
package_manager: Literal["npm", "pnpm", "yarn", "bun"] = "npm"
bootstrap_commands: list[str] = []
install_commands: list[str] = []
class TraefikConfig(BaseModel):
enabled: bool = True
subdomain_prefix: str | None = None
port: int | None = None
middlewares: list[str] = []
strip_prefix: bool = False
entrypoint: str | None = None
cert_resolver: str | None = None
class ToolManifest(BaseModel):
id: str = Field(..., pattern=r"^[a-z0-9\-]+$")
name: str
description: str = ""
version: str = "1.0.0"
image: str
runtime_command: list[str] | None = None
runtime_entrypoint: list[str] | None = None
runtime_user: str | None = None
runtime_working_dir: str | None = None
ports: list[PortConfig] = []
workspace_mounts: list[MountConfig] = []
config_mounts: list[MountConfig] = []
env: dict[str, str] = {}
secrets: list[SecretRef] = []
health_check: HealthCheckConfig | None = None
resource_limits: ResourceLimits | None = None
executable: ExecutableConfig | None = None
traefik: TraefikConfig | None = None
@model_validator(mode="after")
def _check_traefik_primary_port(self) -> ToolManifest:
traefik = self.traefik
if traefik is not None and traefik.enabled:
has_primary = any(port.primary for port in self.ports)
if not has_primary:
raise ValueError(
"at least one port must have primary=True when traefik.enabled is True"
)
return self
-52
View File
@@ -1,52 +0,0 @@
"""In-memory tool manifest registry with YAML file loading."""
from __future__ import annotations
from pathlib import Path
import yaml
from app.tools.models import ToolManifest
class ToolRegistry:
"""In-memory registry for tool manifests."""
def __init__(self) -> None:
self._manifests: dict[str, ToolManifest] = {}
def load_builtin_manifests(self) -> None:
"""Scan the built-in manifests directory and register all *.yml files."""
manifests_dir = Path(__file__).parent / "manifests"
if not manifests_dir.exists():
return
for file_path in sorted(manifests_dir.glob("*.yml")):
self.load_file(file_path)
def load_file(self, path: Path) -> ToolManifest:
"""Load a single YAML manifest file, validate it, and register it."""
data = yaml.safe_load(path.read_text(encoding="utf-8"))
manifest = ToolManifest.model_validate(data)
self.register(manifest)
return manifest
def register(self, manifest: ToolManifest) -> None:
"""Store a manifest in the registry (idempotent upsert)."""
self._manifests[manifest.id] = manifest
def get(self, tool_id: str) -> ToolManifest | None:
"""Retrieve a manifest by tool id, or None if not found."""
return self._manifests.get(tool_id)
def list(self) -> list[ToolManifest]:
"""Return all registered manifests."""
return list(self._manifests.values())
def remove(self, tool_id: str) -> ToolManifest | None:
"""Remove a manifest by tool id and return it, or None if not found."""
return self._manifests.pop(tool_id, None)
# Module-level singleton — callers must explicitly bootstrap via
# registry.load_builtin_manifests() (typically in a FastAPI lifespan).
registry = ToolRegistry()

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