fix: run tool terminal sessions as container user instead of root
- Remove compose-level user: 0:0 override from manifest_compiler.py so the entrypoint can start as root, fix mount ownership, and drop privileges to the container user internally. - Add get_manifest_container_user() helper to resolve the manifest-declared container user (with uid:gid fallback). - Pass container user through TerminalSession, TerminalManager, and the terminal WebSocket handler so docker exec is invoked with --user <user>. - Update and add unit tests for the manifest compiler and terminal session. - Record the additional root-user fix in the fix-pi-container-mount-permissions OpenSpec change/tasks. Quality gates: pytest tests/unit/ (226 passed), pytest tests/services/test_terminal_manager_multi.py (7 passed), ruff check on changed files (clean), mypy on changed files (clean)
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
|
"fingerprint": "e7b3130f52a328d4051e75364e5394ac63df60c6"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Skill Registry — workspace
|
# Skill Registry — headquarter
|
||||||
|
|
||||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
Last updated: 2026-06-05
|
Last updated: 2026-06-17
|
||||||
|
|
||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
@@ -19,11 +19,11 @@ Last updated: 2026-06-05
|
|||||||
|
|
||||||
| Skill | Trigger / description | Scope | Path |
|
| Skill | Trigger / description | Scope | Path |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
|
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/user/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
|
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/user/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
|
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/user/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
|
||||||
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
|
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/user/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
|
||||||
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
|
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/home/user/headquarter/.claude/skills/sift-backlog/SKILL.md` |
|
||||||
|
|
||||||
## Loading protocol
|
## Loading protocol
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -16,7 +16,7 @@ dir: .
|
|||||||
Trust boundary: index routes, map orients, source decides.
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
## role
|
## role
|
||||||
A self-hosted web platform for managing projects, git repositories, and development tools with OAuth2 authentication, built as a Dockerized multi-service application.
|
A self-hosted development platform that manages projects, git repositories, and development tools with OAuth2 authentication, containerized via Docker Compose.
|
||||||
## parent
|
## parent
|
||||||
-
|
-
|
||||||
## children
|
## children
|
||||||
@@ -56,6 +56,9 @@ A self-hosted web platform for managing projects, git repositories, and developm
|
|||||||
- tool-images
|
- tool-images
|
||||||
index: tool-images/.pi-map.index.md
|
index: tool-images/.pi-map.index.md
|
||||||
map: tool-images/.pi-map.md
|
map: tool-images/.pi-map.md
|
||||||
|
- uploads
|
||||||
|
index: uploads/.pi-map.index.md
|
||||||
|
map: uploads/.pi-map.md
|
||||||
## files
|
## files
|
||||||
- .env.example
|
- .env.example
|
||||||
- .gitignore
|
- .gitignore
|
||||||
|
|||||||
+16
-16
@@ -18,25 +18,25 @@ index: ./.pi-map.index.md
|
|||||||
Trust boundary: index routes, map orients, source decides.
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
## role
|
## role
|
||||||
A self-hosted web platform for managing projects, git repositories, and development tools with OAuth2 authentication, built as a Dockerized multi-service application.
|
A self-hosted development platform that manages projects, git repositories, and development tools with OAuth2 authentication, containerized via Docker Compose.
|
||||||
## files
|
## files
|
||||||
- .env.example | Template environment configuration file defining all required and optional environment variables for a multi-service application stack
|
- .env.example | Provides example environment variable configuration for a full-stack application with database, caching, authentication, and deployment settings
|
||||||
- .gitignore | Specifies patterns for files and directories that Git should ignore in this project. | dep: git
|
- .gitignore | Specifies files and directories for Git to ignore across a project using Beads/Dolt, Python, Node, and various IDE/OS tooling. | dep: git, dolt, beads, python, node, npm, yarn, pnpm, pytest, mypy, ruff, coverage
|
||||||
- AGENTS.md | Defines agent behavior rules, workflow procedures, and project conventions for AI agents working on an OpenSpec-driven codebase. | dep: OpenSpec, git, superpowers workflow system, conventional commits
|
- AGENTS.md | Defines operational guidelines and workflows for AI agents collaborating on a software project governed by OpenSpec | dep: OpenSpec, superpowers (brainstorming, writing-plans, test-driven-development, systematic-debugging, verification-before-completion, using-git-worktrees, dispatching-parallel-agents), git
|
||||||
- CHANGELOG.md | Documents version history and notable changes to a project management and Git repository application
|
- CHANGELOG.md | Documents version history and notable changes for a project management and Git repository application
|
||||||
- Makefile | Provides standard development commands for managing a Docker Compose-based multi-service application (API, web, Postgres, Redis) with testing, migration, linting, and build automation. | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm/node
|
- Makefile | Provides standardized development commands for managing a Docker-based full-stack application with API, web frontend, database, and testing infrastructure | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis
|
||||||
- README.md | A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication. | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik
|
- README.md | Documentation for a self-hosted development platform that manages projects, git repositories, and development tools with OAuth2 authentication | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik
|
||||||
- docker-compose.traefik.yml | Deploys a multi-service web application stack (frontend, API, PostgreSQL, Redis) behind an existing Traefik reverse proxy with automatic HTTPS | dep: Docker, Docker Compose, Traefik, PostgreSQL, Redis, Authentik
|
- docker-compose.traefik.yml | Deploys a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination | dep: docker, docker-compose, traefik, postgres, redis, node/vite, python/fastapi
|
||||||
- docker-compose.yml | Defines a multi-container Docker application with PostgreSQL, Redis, API backend, and web frontend services for a "headquarter" application. | dep: Docker Compose, PostgreSQL 15, Redis 7, Python/FastAPI (implied by asyncpg), Vite/Node.js (implied by web build), Alpine Linux
|
- docker-compose.yml | Defines a multi-service Docker Compose stack for a web application with PostgreSQL, Redis, API backend, and web frontend services | dep: Docker, Docker Compose, PostgreSQL, Redis, Vite, asyncpg, Python/FastAPI (implied), Node.js (implied)
|
||||||
- progress.md | Documents the completed phases and remaining work of a backend-frontend code refactoring project involving modularization, subpackage extraction, and file reorganization.
|
- progress.md | Documents the progress and remaining tasks for a backend-frontend refactoring project involving modularization, code reorganization, and verification.
|
||||||
- swap-pane | Swaps two tmux panes between windows, preserving active pane state and layout | dep: tmux
|
- swap-pane | Provides a command to swap the position of two tmux panes within a window or between windows | dep: tmux, client, window, layout, cmd-find, cmd-parse, options
|
||||||
- ui-audit-spacing-typography.md | Documents a comprehensive UI audit of spacing, typography, and visual rhythm issues in a React web application, identifying missing CSS classes, unstyled mobile components, inconsistent design tokens, and layout bugs across component and stylesheet files. | dep: React/TSX components, CSS modules/stylesheets, CSS custom properties (tokens), mobile-specific components, design system tokens
|
- ui-audit-spacing-typography.md | A detailed audit report identifying critical CSS styling gaps, mobile layout failures, and design system inconsistencies in a web application's UI components and stylesheets. | dep: React/TSX components, CSS stylesheets (global.css, utilities.css, tokens.css, page-specific CSS), JSX/TSX files in apps/web/src/components and apps/web/src/pages
|
||||||
- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including token expansion, primitive CSS classes, component updates, and validation results. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, git/SSH, npm build toolchain
|
- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including design token expansion, primitive CSS class additions, component refactors, and OpenSpec documentation. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, Git/SSH, npm build toolchain
|
||||||
- ui-rework-pass2-apply.md | Documents a completed web UI refactoring pass that converted inline styles to token-based utility classes, unified form patterns, and added component utilities across multiple React components. | dep: React, CSS custom properties, OpenSpec, Git, npm, TypeScript, ESLint
|
- ui-rework-pass2-apply.md | Documents the implementation of Pass 2 of a web UI spacing/typography rework, refactoring inline styles into token-based utility classes across multiple components. | dep: React/TSX components, CSS custom properties, OpenSpec documentation system, git workflow
|
||||||
## arch
|
## arch
|
||||||
Full-stack monolithic architecture with React frontend, API backend, PostgreSQL and Redis services, containerized via Docker Compose, using Traefik for reverse proxy/HTTPS, and following a token-based design system for UI consistency.
|
Full-stack containerized architecture with PostgreSQL database, Redis caching, separate API/backend and web frontend services, reverse-proxy ready (Traefik), using a Makefile-driven development workflow with AI agent collaboration via OpenSpec governance.
|
||||||
## tags
|
## tags
|
||||||
docker, application, git, web, react, ui, documents, rework
|
docker, web, git, application, ui, rework, python, redis
|
||||||
## symbols
|
## symbols
|
||||||
-
|
-
|
||||||
## workflows
|
## workflows
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ index: apps/.pi-map.index.md
|
|||||||
Contains the main application entry points and executable modules for the project.
|
Contains the main application entry points and executable modules for the project.
|
||||||
## files
|
## files
|
||||||
## arch
|
## arch
|
||||||
Modular application structure with separate deployable units, likely following microservices or multi-app monorepo pattern with shared infrastructure.
|
Top-level directory for deployable application boundaries, typically organized by target platform or deployment variant (e.g., web, mobile, desktop, CLI).
|
||||||
## tags
|
## tags
|
||||||
-
|
-
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,14 +2,23 @@
|
|||||||
dir: apps/api
|
dir: apps/api
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI-based backend API service for managing projects, git repositories, and development tools in a self-hosted development platform.
|
FastAPI backend API that manages projects, git repositories, development tools, and tool instances via Docker Compose
|
||||||
## parent
|
## parent
|
||||||
index: apps/.pi-map.index.md
|
index: apps/.pi-map.index.md
|
||||||
map: apps/.pi-map.md
|
map: apps/.pi-map.md
|
||||||
## children
|
## children
|
||||||
|
- apps/api/.mypy_cache
|
||||||
|
index: apps/api/.mypy_cache/.pi-map.index.md
|
||||||
|
map: apps/api/.mypy_cache/.pi-map.md
|
||||||
- apps/api/.pi-lens
|
- apps/api/.pi-lens
|
||||||
index: apps/api/.pi-lens/.pi-map.index.md
|
index: apps/api/.pi-lens/.pi-map.index.md
|
||||||
map: apps/api/.pi-lens/.pi-map.md
|
map: apps/api/.pi-lens/.pi-map.md
|
||||||
|
- apps/api/.pytest_cache
|
||||||
|
index: apps/api/.pytest_cache/.pi-map.index.md
|
||||||
|
map: apps/api/.pytest_cache/.pi-map.md
|
||||||
|
- apps/api/.ruff_cache
|
||||||
|
index: apps/api/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/.ruff_cache/.pi-map.md
|
||||||
- apps/api/alembic
|
- apps/api/alembic
|
||||||
index: apps/api/alembic/.pi-map.index.md
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
map: apps/api/alembic/.pi-map.md
|
map: apps/api/alembic/.pi-map.md
|
||||||
|
|||||||
+10
-10
@@ -4,19 +4,19 @@ dir: apps/api
|
|||||||
index: apps/api/.pi-map.index.md
|
index: apps/api/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI-based backend API service for managing projects, git repositories, and development tools in a self-hosted development platform.
|
FastAPI backend API that manages projects, git repositories, development tools, and tool instances via Docker Compose
|
||||||
## files
|
## files
|
||||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance. | dep: Docker
|
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid caching unnecessary files | dep: Docker
|
||||||
- Dockerfile | Multi-stage Docker image for a Python web application with Docker-in-Docker capabilities, database waiting, and Cloudflare tunnel support | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
|
- Dockerfile | Multi-stage Docker build for a Python web application with Docker socket access, database connectivity, and Cloudflare tunnel support | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
|
||||||
- README.md | README documentation for Headquarter API, a self-hosted FastAPI backend for managing projects, git repositories, and development tools. | dep: FastAPI, SQLAlchemy, PostgreSQL, asyncpg, Alembic, Docker, Docker Compose, Authentik, uvicorn, pytest, ruff, mypy
|
- README.md | README documentation for a self-hosted FastAPI backend API that manages projects, git repositories, development tools, and tool instances via Docker Compose | dep: FastAPI, SQLAlchemy, PostgreSQL, asyncpg, Alembic, Docker, Docker Compose, Authentik, uvicorn, pytest, ruff, mypy
|
||||||
- alembic.ini | Configuration file for Alembic database migration tool specifying script location, database connection URL, and logging settings | dep: alembic, sqlalchemy, asyncpg, postgresql
|
- alembic.ini | Configuration file for Alembic database migration tool, specifying script location, database connection URL, and logging settings | dep: alembic, sqlalchemy, asyncpg, postgresql
|
||||||
- pyproject.toml | Defines Python project configuration, dependencies, and tool settings for a FastAPI-based backend API service | dep: fastapi, uvicorn, sqlalchemy, asyncpg, alembic, pydantic, pydantic-settings, python-multipart, httpx, structlog, cryptography, pytest, pytest-asyncio, mypy, ruff, aiosqlite
|
- pyproject.toml | Defines Python project metadata, dependencies, and tool configurations for a FastAPI-based backend API called "headquarter-api" | dep: fastapi, uvicorn, sqlalchemy, asyncpg, alembic, pydantic, pydantic-settings, python-multipart, httpx, structlog, cryptography, pytest, pytest-asyncio, mypy, ruff, aiosqlite
|
||||||
- uv.lock | Lock file generated by uv package manager that records exact dependency versions, hashes, and download URLs for reproducible Python environment installation | dep: uv, PyPI, python
|
- uv.lock | Lock file generated by the uv Python package manager that pins exact dependency versions with cryptographic hashes for reproducible installations | dep: uv, python, pypi, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, mako, sqlalchemy, typing-extensions, idna
|
||||||
- wait-for-db.sh | Waits for a PostgreSQL database to become available before executing the provided command | dep: nc (netcat), sh, sleep
|
- wait-for-db.sh | Waits for a PostgreSQL database to become available by polling its TCP port before executing subsequent commands. | dep: nc (netcat), sh (POSIX shell), sleep
|
||||||
## arch
|
## arch
|
||||||
Modern Python backend with FastAPI, SQLAlchemy/ORM with Alembic migrations, uv for dependency management, multi-stage Docker containerization with Docker-in-Docker support, and PostgreSQL database connectivity.
|
Containerized Python microservice using FastAPI, SQLAlchemy with Alembic migrations, multi-stage Docker builds, and Docker Compose orchestration with external database dependency
|
||||||
## tags
|
## tags
|
||||||
docker, alembic, python, database, fastapi, uvicorn, pyproject, readme
|
docker, alembic, python, database, fastapi, sqlalchemy, asyncpg, uvicorn
|
||||||
## symbols
|
## symbols
|
||||||
-
|
-
|
||||||
## workflows
|
## workflows
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/src
|
dir: apps/api/src
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Core backend API package for the "Headquarter API" FastAPI application, handling configuration, database setup, logging, and application bootstrap.
|
Core FastAPI web application package that bootstraps and configures the Headquarter API service with database, authentication, logging, and modular routing infrastructure.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/.pi-map.index.md
|
index: apps/api/.pi-map.index.md
|
||||||
map: apps/api/.pi-map.md
|
map: apps/api/.pi-map.md
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ dir: apps/api/src
|
|||||||
index: apps/api/src/.pi-map.index.md
|
index: apps/api/src/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Core backend API package for the "Headquarter API" FastAPI application, handling configuration, database setup, logging, and application bootstrap.
|
Core bootstrap and infrastructure package for the Headquarter API FastAPI application, handling configuration, database connectivity, logging, and application startup.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Marks the directory as a Python package for the Headquarter API.
|
- __init__.py | Marks the directory as a Python package for the Headquarter API.
|
||||||
- config.py | Defines application configuration settings with environment variable loading, database URL construction, and computed properties for service URLs and security settings. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
|
- config.py | Defines application configuration settings with environment-based overrides using Pydantic, including database URLs, domain/public URL resolution, Authentik OAuth integration, session/JWT settings, and cookie security policies. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
|
||||||
- database.py | Sets up an async SQLAlchemy database engine with retry logic and runs Alembic migrations via subprocess to initialize the database. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:os.path.dirname, call:os.path.abspath, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, os, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
|
- database.py | Configures an async SQLAlchemy database engine with session management and provides retry logic for database initialization via Alembic migrations. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:os.path.dirname, call:os.path.abspath, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, os, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
|
||||||
- logging_config.py | Configures structured JSON logging with correlation ID injection, request/response logging middleware, and exception handling for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation, starlette
|
- logging_config.py | Configures structured JSON logging with correlation IDs and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation, starlette
|
||||||
- main.py | Bootstraps a FastAPI application called "Headquarter API" with database initialization, health monitoring, CORS, logging middleware, and modular API routers. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api
|
- main.py | Bootstraps a FastAPI application for "Headquarter API" with CORS, logging, validation error handling, database initialization, health monitoring, and registration of modular API routers. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
|
||||||
## arch
|
## arch
|
||||||
Layered architecture with separation of concerns across config (settings/env), database (async SQLAlchemy with Alembic migrations), logging (structured JSON with middleware), and main (app composition with modular routers and health monitoring).
|
Layered infrastructure pattern with Pydantic-based settings management, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation ID tracking, and modular FastAPI router registration with middleware composition.
|
||||||
## tags
|
## tags
|
||||||
src, logging, database, api, call:logger.info, middleware, fastapi, filter
|
src, database, logging, api, call:logger.info, fastapi, filter, call:logging.get
|
||||||
## symbols
|
## symbols
|
||||||
- Settings
|
- Settings
|
||||||
- CorrelationIdFilter
|
- CorrelationIdFilter
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/src/api
|
dir: apps/api/src/api
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides shared Pydantic validators and package initialization for API schema validation across the API layer.
|
Defines reusable Pydantic validation utilities for API schema fields used across API endpoints.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/src/.pi-map.index.md
|
index: apps/api/src/.pi-map.index.md
|
||||||
map: apps/api/src/.pi-map.md
|
map: apps/api/src/.pi-map.md
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ dir: apps/api/src/api
|
|||||||
index: apps/api/src/api/.pi-map.index.md
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides shared Pydantic validators and package initialization for API schema validation across the API layer.
|
Defines reusable Pydantic validation utilities for API schema fields used across API endpoints.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Marks the directory as a Python package for API routers.
|
- __init__.py | Marks the directory as a Python package for API routers.
|
||||||
- shared_validators.py | Provides reusable Pydantic validators for API schema fields including mount paths, file dictionaries, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
|
- shared_validators.py | Provides reusable Pydantic validation functions for API schema fields including mount paths, file uploads, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
|
||||||
## arch
|
## arch
|
||||||
Utility module pattern with reusable cross-cutting Pydantic validators for common API field types (mount paths, files, env vars, volumes).
|
Utility module pattern with shared validation functions for common Docker/container resource types (mounts, volumes, files, environment variables).
|
||||||
## tags
|
## tags
|
||||||
validate, raise:value, error, call:isinstance, validators, mount, api, init
|
validate, raise:value, error, call:isinstance, mount, api, init, path
|
||||||
## symbols
|
## symbols
|
||||||
- validate_mount_path
|
- validate_mount_path
|
||||||
- validate_files
|
- validate_files
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/src/api/system
|
dir: apps/api/src/api/system
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides system-level API endpoints for monitoring, administration, and operational infrastructure including health checks, dashboards, real-time events, notifications, and container terminal access.
|
Provides system-level infrastructure endpoints for monitoring, real-time communication, and instance management in the API service.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/src/api/.pi-map.index.md
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
map: apps/api/src/api/.pi-map.md
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ dir: apps/api/src/api/system
|
|||||||
index: apps/api/src/api/system/.pi-map.index.md
|
index: apps/api/src/api/system/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides system-level API endpoints for monitoring, administration, and operational infrastructure including health checks, dashboards, real-time events, notifications, and container terminal access.
|
Provides system-level infrastructure endpoints for monitoring, real-time communication, and instance management in the API service.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Aggregates and re-exports system API routers from submodules for centralized access. | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
|
- __init__.py | Aggregates and re-exports system API router modules for centralized access | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
|
||||||
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with counts of projects, repositories, SSH keys, and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project
|
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with aggregated counts and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project
|
||||||
- events.py | Implements an SSE streaming endpoint that broadcasts instance events to authenticated users with per-user connection limits and backpressure handling. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
|
- events.py | Implements an SSE streaming endpoint that delivers instance events to authenticated users with per-user connection limiting and automatic heartbeat pings. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
|
||||||
- health.py | Provides FastAPI health check endpoints that monitor database connectivity, disk space, and system uptime with performance timing. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
|
- health.py | Provides FastAPI health check endpoints that monitor system health including database connectivity/response time and disk usage, returning structured health status responses. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
|
||||||
- instance_proxy.py | HTTP proxy router that forwards incoming requests to running containerized tool instances after verifying ownership and instance status. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
|
- instance_proxy.py | HTTP request proxy router that forwards requests from authenticated users to their running containerized tool instances. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
|
||||||
- notifications.py | Provides FastAPI REST endpoints for managing user notifications including listing, marking read/unread, dismissing, and clearing all notifications with support for muted categories. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy
|
- notifications.py | Defines FastAPI REST endpoints for managing user notifications (list, unread count, mark read, dismiss, clear all) with support for muted categories. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy, src.models.UserConfig
|
||||||
- terminal.py | Provides WebSocket and REST endpoints for browser-based terminal access to running Docker container tool instances, supporting multiple named sessions, authentication, and terminal reset/reconnect functionality. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
|
- terminal.py | Provides WebSocket endpoints for browser-based terminal access to running tool instances, handling session lifecycle, authentication, input/output streaming, and terminal resize/reset operations. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_resolve_container_user(db_session: AsyncSession, instance: ToolInstance) → str | None, call:db_session.get, call:dict, call:resolve_base, call:deep_merge, call:get_manifest_container_user, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:_resolve_container_user, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:_resolve_container_user, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:_resolve_container_user, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:_resolve_container_user, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.build.manifest_compiler, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
|
||||||
## arch
|
## arch
|
||||||
FastAPI router composition pattern with modular sub-routers aggregated via __init__.py, combining synchronous REST endpoints, SSE streaming, and WebSocket connections for real-time features, with authentication enforcement and resource ownership validation across operational and infrastructure concerns.
|
Modular FastAPI router composition with separate modules for distinct protocols (REST/SSE/WebSocket/proxy), each handling authentication, connection lifecycle management, and resource-specific business logic.
|
||||||
## tags
|
## tags
|
||||||
terminal, session, call:terminal, call:, get, src, response, instance
|
terminal, call:, session, call:terminal, src, get, response, websocket
|
||||||
## symbols
|
## symbols
|
||||||
- NotificationItem
|
- NotificationItem
|
||||||
- NotificationListResponse
|
- NotificationListResponse
|
||||||
|
|||||||
@@ -13,9 +13,18 @@ from starlette.websockets import WebSocketDisconnect
|
|||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import TerminalSessionModel
|
from src.models import TerminalSessionModel
|
||||||
|
from src.models import ToolDefinitionManifest
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models import ToolType
|
from src.models import ToolType
|
||||||
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
|
from src.services.build.manifest_compiler import (
|
||||||
|
deep_merge,
|
||||||
|
get_manifest_container_user,
|
||||||
|
resolve_base,
|
||||||
|
)
|
||||||
|
from src.services.terminal.terminal_manager import (
|
||||||
|
MaxSessionsExceededError,
|
||||||
|
terminal_manager,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -57,6 +66,35 @@ async def terminal_websocket_specific(
|
|||||||
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_container_user(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve the container user for docker exec from the tool manifest.
|
||||||
|
|
||||||
|
For manifest-based tools, the user declared in the manifest (or its base
|
||||||
|
definition) is returned so terminal sessions run with the same privileges
|
||||||
|
as the main container process. Legacy tools return None, preserving the
|
||||||
|
previous behavior.
|
||||||
|
"""
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type or tool_type.definition_type != "manifest":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not tool_type.manifest_id or not tool_type.manifest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest = dict(tool_type.manifest.manifest)
|
||||||
|
if tool_type.manifest.base_definition_id:
|
||||||
|
base_def = await db_session.get(
|
||||||
|
ToolDefinitionManifest, tool_type.manifest.base_definition_id
|
||||||
|
)
|
||||||
|
if base_def:
|
||||||
|
manifest = resolve_base(deep_merge(dict(base_def.manifest), manifest))
|
||||||
|
|
||||||
|
return get_manifest_container_user(manifest)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_terminal_websocket(
|
async def _handle_terminal_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
instance_id: str,
|
instance_id: str,
|
||||||
@@ -139,7 +177,7 @@ async def _handle_terminal_websocket(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fetch tool type to get startup_command
|
# Fetch tool type to get startup_command and container_user
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
if startup_command:
|
if startup_command:
|
||||||
@@ -149,6 +187,14 @@ async def _handle_terminal_websocket(
|
|||||||
startup_command,
|
startup_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
if container_user:
|
||||||
|
logger.debug(
|
||||||
|
"Terminal sessions for instance %s will run as user %s",
|
||||||
|
instance_id,
|
||||||
|
container_user,
|
||||||
|
)
|
||||||
|
|
||||||
session = None
|
session = None
|
||||||
|
|
||||||
# Get or create terminal session
|
# Get or create terminal session
|
||||||
@@ -159,6 +205,7 @@ async def _handle_terminal_websocket(
|
|||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
slot_session_id = "default"
|
slot_session_id = "default"
|
||||||
else:
|
else:
|
||||||
@@ -189,6 +236,7 @@ async def _handle_terminal_websocket(
|
|||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name=db_row.name,
|
name=db_row.name,
|
||||||
session_id=target_session_id,
|
session_id=target_session_id,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -318,6 +366,7 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
startup_command=session.startup_command,
|
startup_command=session.startup_command,
|
||||||
session_id=session_ref.slot_session_id,
|
session_id=session_ref.slot_session_id,
|
||||||
name=session.name,
|
name=session.name,
|
||||||
|
container_user=session.container_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the mutable session reference
|
# Update the mutable session reference
|
||||||
@@ -486,6 +535,7 @@ async def create_terminal_session(
|
|||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
name = data.get("name")
|
name = data.get("name")
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.create_session(
|
||||||
@@ -493,6 +543,7 @@ async def create_terminal_session(
|
|||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name=name,
|
name=name,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
except MaxSessionsExceededError:
|
except MaxSessionsExceededError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -595,6 +646,7 @@ async def reset_specific_terminal_session(
|
|||||||
# Preserve name if possible
|
# Preserve name if possible
|
||||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
name = live_session.name if live_session else None
|
name = live_session.name if live_session else None
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
|
||||||
new_session = await terminal_manager.reset_session(
|
new_session = await terminal_manager.reset_session(
|
||||||
instance_id,
|
instance_id,
|
||||||
@@ -602,6 +654,7 @@ async def reset_specific_terminal_session(
|
|||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
session_id=key[1],
|
session_id=key[1],
|
||||||
name=name,
|
name=name,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -687,6 +740,7 @@ async def reset_terminal_session(
|
|||||||
# Fetch tool type to get startup_command
|
# Fetch tool type to get startup_command
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Reset the default session
|
# Reset the default session
|
||||||
@@ -694,6 +748,7 @@ async def reset_terminal_session(
|
|||||||
instance_id,
|
instance_id,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/src/services
|
dir: apps/api/src/services
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides utility services for the API application, including tmux window management functionality.
|
Provides a command to swap the position of two tmux panes within a window or between windows
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/src/.pi-map.index.md
|
index: apps/api/src/.pi-map.index.md
|
||||||
map: apps/api/src/.pi-map.md
|
map: apps/api/src/.pi-map.md
|
||||||
|
|||||||
@@ -408,7 +408,6 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
|||||||
Docker Compose YAML content.
|
Docker Compose YAML content.
|
||||||
"""
|
"""
|
||||||
runtime = manifest.get("runtime", {})
|
runtime = manifest.get("runtime", {})
|
||||||
user = manifest.get("user")
|
|
||||||
interface_type = manifest["interface_type"]
|
interface_type = manifest["interface_type"]
|
||||||
home_dir = get_manifest_home_dir(manifest)
|
home_dir = get_manifest_home_dir(manifest)
|
||||||
|
|
||||||
@@ -436,12 +435,11 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
|||||||
else:
|
else:
|
||||||
service["working_dir"] = f"{home_dir}/{workspace_name}"
|
service["working_dir"] = f"{home_dir}/{workspace_name}"
|
||||||
|
|
||||||
# The entrypoint starts as root (Dockerfile does not set USER) so it can
|
# The Dockerfile does not set USER so the entrypoint starts as root,
|
||||||
# fix mount ownership. It drops privileges to the container user internally
|
# fixes mount ownership, and drops privileges to the container user
|
||||||
# before exec-ing the real command, so do not set compose-level user
|
# internally. Do not set a compose-level user override: that would pin
|
||||||
# override here.
|
# the container metadata to root and make docker exec sessions run as
|
||||||
if user:
|
# root even after the entrypoint drops privileges.
|
||||||
service["user"] = "0:0"
|
|
||||||
|
|
||||||
# Ports for web tools
|
# Ports for web tools
|
||||||
default_port = manifest.get("default_port")
|
default_port = manifest.get("default_port")
|
||||||
@@ -568,6 +566,35 @@ def get_manifest_home_dir(manifest: dict) -> str:
|
|||||||
return "/root"
|
return "/root"
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_container_user(manifest: dict) -> str | None:
|
||||||
|
"""Resolve the container user identifier from a manifest.
|
||||||
|
|
||||||
|
Returns the user name when available so that docker exec sessions can
|
||||||
|
attach as the container user instead of defaulting to root. Falls back
|
||||||
|
to ``uid:gid`` when a name is absent but numeric ids are present.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User name (e.g. ``user``), ``uid:gid`` string, or None when the
|
||||||
|
manifest does not declare a user.
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = user.get("name")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
uid = user.get("uid")
|
||||||
|
gid = user.get("gid")
|
||||||
|
if uid is not None and gid is not None:
|
||||||
|
return f"{uid}:{gid}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||||
"""Compute a deterministic image tag from manifest content.
|
"""Compute a deterministic image tag from manifest content.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/src/services/terminal
|
dir: apps/api/src/services/terminal
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides backend infrastructure for managing interactive WebSocket-based terminal sessions within Docker containers, including session lifecycle, PTY I/O handling, and resource cleanup.
|
Provides WebSocket-based terminal session management for containerized environments with PTY support, session persistence, and resource limits.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/src/services/.pi-map.index.md
|
index: apps/api/src/services/.pi-map.index.md
|
||||||
map: apps/api/src/services/.pi-map.md
|
map: apps/api/src/services/.pi-map.md
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ dir: apps/api/src/services/terminal
|
|||||||
index: apps/api/src/services/terminal/.pi-map.index.md
|
index: apps/api/src/services/terminal/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides backend infrastructure for managing interactive WebSocket-based terminal sessions within Docker containers, including session lifecycle, PTY I/O handling, and resource cleanup.
|
Provides WebSocket-based terminal session management for containerized environments with PTY support, session persistence, and resource limits.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Package initialization file that exports the public API for the terminal services module | dep: src.services.terminal.terminal_manager, src.services.terminal.terminal_session
|
- __init__.py | Exports the public API for the terminal services module by re-exporting TerminalManager, TerminalSession, and MaxSessionsExceededError. | dep: src.services.terminal.terminal_manager, src.services.terminal.terminal_session
|
||||||
- terminal_manager.py | Manages active terminal sessions with WebSocket support, idle cleanup, database persistence, and per-instance session limits. | exp: class:MaxSessionsExceededError, method:__init__(self, instance_id: str, max_sessions) → None, call:super().__init__, class:TerminalManager, method:__init__(self) → None, call:self._start_idle_check, method:_start_idle_check(self) → None, call:self._idle_check_task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._idle_check_loop, method:_idle_check_loop(self) → None, call:asyncio.sleep, call:self._cleanup_idle_sessions, call:logger.error, method:_cleanup_idle_sessions(self) → None, call:list, call:self._sessions.items, call:session.is_idle, call:idle_keys.append, call:logger.info, call:self._sessions.pop, call:session.close, call:asyncio.create_task, call:self._mark_closed_in_db, method:_insert_db_session_row(self, session_id: str, instance_id: uuid.UUID, name: str) → None, call:SessionLocal, call:pg_insert(TerminalSessionModel) .values( id=uuid.UUID(session_id), instance_id=instance_id, name=name, status="active", created_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc), ) .on_conflict_do_nothing, call:uuid.UUID, call:datetime.now, call:db_session.execute, call:db_session.commit, call:logger.debug, call:logger.error, method:_mark_closed_in_db(self, session_id: str) → None, call:SessionLocal, call:db_session.get, call:uuid.UUID, call:datetime.now, call:db_session.commit, call:logger.debug, call:logger.error, method:_count_sessions_for_instance(self, instance_id_str: str) → int, call:sum, method:create_session(self, instance_id: uuid.UUID, container_id: str, startup_command, name, session_id) → TerminalSession, call:str, call:self._count_sessions_for_instance, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, call:logger.info, raise:MaxSessionsExceededError, method:get_or_create_session(self, instance_id: uuid.UUID, container_id: str, startup_command) → TerminalSession, call:self._start_idle_check, call:str, call:session.is_alive, call:logger.debug, call:session.close, call:logger.info, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, method:get_session(self, instance_id: str, session_id: str) → TerminalSession | None, call:self._sessions.get, call:self._sessions.items, method:_find_key_by_internal_id(self, instance_id: str, internal_session_id: str) → tuple[str, str] | None, call:self._sessions.items, method:get_sessions_for_instance(self, instance_id: str) → list[TerminalSession], call:self._sessions.items, method:close_session(self, instance_id: str, session_id: str) → None, call:self._sessions.pop, call:session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:logger.info, method:attach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.has_websockets, call:logger.debug, call:list, call:ws.close, call:session._websockets.clear, call:session.attach_websocket, call:session.get_buffer, call:websocket.send_bytes, method:detach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.detach_websocket, method:reset_session(self, instance_id: uuid.UUID, container_id: str, startup_command, session_id, name) → TerminalSession, call:str, call:logger.debug, call:self._sessions.pop, call:old_session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:uuid.uuid4, call:TerminalSession, call:new_session.start, call:self._insert_db_session_row, method:close_all(self) → None, call:list, call:self._sessions.values, call:self._sessions.clear, call:session.close, call:self._idle_check_task.done, call:self._idle_check_task.cancel | dep: asyncio, logging, uuid, datetime, fastapi, sqlalchemy.dialects.postgresql, src.database, src.models, src.services.terminal.terminal_session, fastapi.WebSocket
|
- terminal_manager.py | Manages WebSocket-based terminal sessions with persistence, idle cleanup, and per-instance session limits for containerized environments. | exp: class:MaxSessionsExceededError, method:__init__(self, instance_id: str, max_sessions) → None, call:super().__init__, class:TerminalManager, method:__init__(self) → None, call:self._start_idle_check, method:_start_idle_check(self) → None, call:self._idle_check_task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._idle_check_loop, method:_idle_check_loop(self) → None, call:asyncio.sleep, call:self._cleanup_idle_sessions, call:logger.error, method:_cleanup_idle_sessions(self) → None, call:list, call:self._sessions.items, call:session.is_idle, call:idle_keys.append, call:logger.info, call:self._sessions.get, call:idle_session.close, call:asyncio.create_task, call:self._mark_closed_in_db, method:_insert_db_session_row(self, session_id: str, instance_id: uuid.UUID, name: str) → None, call:SessionLocal, call:pg_insert(TerminalSessionModel) .values( id=uuid.UUID(session_id), instance_id=instance_id, name=name, status="active", created_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc), ) .on_conflict_do_nothing, call:uuid.UUID, call:datetime.now, call:db_session.execute, call:db_session.commit, call:logger.debug, call:logger.error, method:_mark_closed_in_db(self, session_id: str) → None, call:SessionLocal, call:db_session.get, call:uuid.UUID, call:datetime.now, call:db_session.commit, call:logger.debug, call:logger.error, method:_count_sessions_for_instance(self, instance_id_str: str) → int, call:sum, method:create_session(self, instance_id: uuid.UUID, container_id: str, startup_command, name, session_id, container_user) → TerminalSession, call:str, call:self._count_sessions_for_instance, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, call:logger.info, raise:MaxSessionsExceededError, method:get_or_create_session(self, instance_id: uuid.UUID, container_id: str, startup_command, container_user) → TerminalSession, call:self._start_idle_check, call:str, call:session.is_alive, call:logger.debug, call:session.close, call:logger.info, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, method:get_session(self, instance_id: str, session_id: str) → TerminalSession | None, call:self._sessions.get, call:self._sessions.items, method:_find_key_by_internal_id(self, instance_id: str, internal_session_id: str) → tuple[str, str] | None, call:self._sessions.items, method:get_sessions_for_instance(self, instance_id: str) → list[TerminalSession], call:self._sessions.items, method:close_session(self, instance_id: str, session_id: str) → None, call:self._sessions.pop, call:session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:logger.info, method:attach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.has_websockets, call:logger.debug, call:list, call:ws.close, call:session._websockets.clear, call:session.attach_websocket, call:session.get_buffer, call:websocket.send_bytes, method:detach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.detach_websocket, method:reset_session(self, instance_id: uuid.UUID, container_id: str, startup_command, session_id, name, container_user) → TerminalSession, call:str, call:logger.debug, call:self._sessions.pop, call:old_session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:uuid.uuid4, call:TerminalSession, call:new_session.start, call:self._insert_db_session_row, method:close_all(self) → None, call:list, call:self._sessions.values, call:self._sessions.clear, call:session.close, call:self._idle_check_task.done, call:self._idle_check_task.cancel | dep: asyncio, logging, uuid, datetime, fastapi, sqlalchemy.dialects.postgresql, src.database, src.models, src.services.terminal.terminal_session, fastapi.WebSocket
|
||||||
- terminal_session.py | Manages a high-performance terminal session using asyncio-native event-driven I/O with PTY for docker exec processes, featuring output batching, flow control, and WebSocket broadcasting. | exp: class:TerminalSession, method:__init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command, name) → None, call:deque, call:set, call:time.time, call:self._generate_name, call:str, call:bytearray, call:asyncio.Lock, method:start(self, startup_command) → None, call:pty.openpty, call:self._set_terminal_size, call:logger.debug, call:asyncio.create_subprocess_exec, call:os.close, call:time.time, call:self._start_reading, method:_start_reading(self) → None, call:asyncio.get_event_loop, call:loop.add_reader, call:logger.debug, call:logger.error, method:_stop_reading(self) → None, call:asyncio.get_event_loop, call:loop.remove_reader, method:_on_fd_readable(self) → None, call:os.read, call:logger.debug, call:self._handle_eof, call:self._add_to_buffer, call:time.time, call:self._queue_output, method:_add_to_buffer(self, data: bytes) → None, call:self._output_buffer.append, call:len, call:self._output_buffer.popleft, method:_queue_output(self, data: bytes) → None, call:self._batch_buffer.extend, call:len, call:self._pause_output, call:asyncio.get_event_loop, call:loop.call_later, method:_flush_batch_sync(self) → None, call:self._batch_buffer.clear, call:bytes, call:set, call:list, call:asyncio.create_task, call:self._send_bytes, call:dead_sockets.add, method:_send_bytes(self, ws: Any, payload: bytes) → None, call:ws.send_bytes, call:self._websockets.discard, method:acknowledge_data(self, char_count: int) → None, call:max, call:self._resume_output, call:self._ack_timeout_handle.cancel, call:asyncio.get_event_loop, call:loop.call_later, method:_ack_timeout_fallback(self) → None, call:logger.warning, call:self._resume_output, method:_pause_output(self) → None, call:self._stop_reading, call:logger.debug, method:_resume_output(self) → None, call:self._start_reading, call:logger.debug, method:get_buffer(self) → bytes, call:b"".join, method:_handle_eof(self) → None, call:self._stop_reading, call:self.process._transport.close, call:set, call:self._websockets.clear, call:asyncio.create_task, call:ws.close, call:logger.info, method:write_input(self, data: bytes) → None, call:os.write, call:time.time, call:logger.debug, call:self._handle_eof, method:_set_terminal_size(self, cols: int, rows: int) → None, call:logger.warning, call:struct.pack, call:fcntl.ioctl, call:logger.debug, call:logger.error, method:resize(self, cols: int, rows: int) → None, call:logger.warning, call:logger.debug, call:self._set_terminal_size, call:os.kill, method:reset(self) → None, call:self.close, call:self._output_buffer.clear, call:self._websockets.clear, call:self._batch_buffer.clear, method:close(self) → None, call:self._stop_reading, call:self._batch_timer.cancel, call:self._ack_timeout_handle.cancel, call:os.close, call:self.process.kill, call:asyncio.wait_for, call:self.process.wait, method:is_alive(self) → bool, method:is_idle(self) → bool, call:time.time, method:attach_websocket(self, websocket: Any) → None, call:self._websockets.add, call:time.time, method:detach_websocket(self, websocket: Any) → None, call:self._websockets.discard, method:has_websockets(self) → bool, call:len, method:send_to_all(self, data: bytes) → None, call:set, call:ws.send_bytes, call:dead_sockets.add, call:self._websockets.discard, method:read_output(self) → bytes | dep: asyncio, logging, os, pty, signal, struct, fcntl, time, uuid, collections, typing, collections.deque, typing.Any
|
- terminal_session.py | Manages an asyncio-native terminal session using PTY with docker exec, featuring event-driven I/O, output batching, and flow control for WebSocket clients. | exp: class:TerminalSession, method:__init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command, name, container_user) → None, call:deque, call:set, call:time.time, call:self._generate_name, call:str, call:bytearray, call:asyncio.Lock, method:start(self, startup_command) → None, call:exec_cmd.extend, call:pty.openpty, call:self._set_terminal_size, call:logger.debug, call:asyncio.create_subprocess_exec, call:os.close, call:time.time, call:self._start_reading, method:_start_reading(self) → None, call:asyncio.get_event_loop, call:loop.add_reader, call:logger.debug, call:logger.error, method:_stop_reading(self) → None, call:asyncio.get_event_loop, call:loop.remove_reader, method:_on_fd_readable(self) → None, call:os.read, call:logger.debug, call:self._handle_eof, call:self._add_to_buffer, call:time.time, call:self._queue_output, method:_add_to_buffer(self, data: bytes) → None, call:self._output_buffer.append, call:len, call:self._output_buffer.popleft, method:_queue_output(self, data: bytes) → None, call:self._batch_buffer.extend, call:len, call:self._pause_output, call:asyncio.get_event_loop, call:loop.call_later, method:_flush_batch_sync(self) → None, call:self._batch_buffer.clear, call:bytes, call:set, call:list, call:asyncio.create_task, call:self._send_bytes, call:dead_sockets.add, method:_send_bytes(self, ws: Any, payload: bytes) → None, call:ws.send_bytes, call:self._websockets.discard, method:acknowledge_data(self, char_count: int) → None, call:max, call:self._resume_output, call:self._ack_timeout_handle.cancel, call:asyncio.get_event_loop, call:loop.call_later, method:_ack_timeout_fallback(self) → None, call:logger.warning, call:self._resume_output, method:_pause_output(self) → None, call:self._stop_reading, call:logger.debug, method:_resume_output(self) → None, call:self._start_reading, call:logger.debug, method:get_buffer(self) → bytes, call:b"".join, method:_handle_eof(self) → None, call:self._stop_reading, call:self.process._transport.close, call:set, call:self._websockets.clear, call:asyncio.create_task, call:ws.close, call:logger.info, method:write_input(self, data: bytes) → None, call:os.write, call:time.time, call:logger.debug, call:self._handle_eof, method:_set_terminal_size(self, cols: int, rows: int) → None, call:logger.warning, call:struct.pack, call:fcntl.ioctl, call:logger.debug, call:logger.error, method:resize(self, cols: int, rows: int) → None, call:logger.warning, call:logger.debug, call:self._set_terminal_size, call:os.kill, method:reset(self) → None, call:self.close, call:self._output_buffer.clear, call:self._websockets.clear, call:self._batch_buffer.clear, method:close(self) → None, call:self._stop_reading, call:self._batch_timer.cancel, call:self._ack_timeout_handle.cancel, call:os.close, call:self.process.kill, call:asyncio.wait_for, call:self.process.wait, method:is_alive(self) → bool, method:is_idle(self) → bool, call:time.time, method:attach_websocket(self, websocket: Any) → None, call:self._websockets.add, call:time.time, method:detach_websocket(self, websocket: Any) → None, call:self._websockets.discard, method:has_websockets(self) → bool, call:len, method:send_to_all(self, data: bytes) → None, call:set, call:ws.send_bytes, call:dead_sockets.add, call:self._websockets.discard, method:read_output(self) → bytes | dep: asyncio, logging, os, pty, signal, struct, fcntl, time, uuid, collections, typing, collections.deque, typing.Any
|
||||||
## arch
|
## arch
|
||||||
Asyncio-native event-driven architecture with producer-consumer pattern for terminal output batching, WebSocket pub/sub broadcasting, session state machine with idle timeout and database persistence, and per-instance resource limits enforced by a centralized manager.
|
Asyncio-native event-driven architecture using PTY/docker exec with manager pattern (TerminalManager orchestrates TerminalSession instances), featuring output batching, flow control, idle cleanup, and per-instance session limiting.
|
||||||
## tags
|
## tags
|
||||||
call:self., session, call:logger.debug, idle, output, terminal, check, task
|
call:self., session, call:logger.debug, idle, output, terminal, check, task
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ class TerminalManager:
|
|||||||
session_id,
|
session_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
)
|
)
|
||||||
session = self._sessions.pop(key, None)
|
idle_session = self._sessions.get(key)
|
||||||
if session:
|
if idle_session is not None:
|
||||||
await session.close()
|
del self._sessions[key]
|
||||||
|
await idle_session.close()
|
||||||
# Update DB status fire-and-forget
|
# Update DB status fire-and-forget
|
||||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ class TerminalManager:
|
|||||||
startup_command: str | None = None,
|
startup_command: str | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
|
container_user: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Create a new terminal session for an instance.
|
"""Create a new terminal session for an instance.
|
||||||
|
|
||||||
@@ -152,6 +154,8 @@ class TerminalManager:
|
|||||||
container_id: Docker container ID.
|
container_id: Docker container ID.
|
||||||
startup_command: Optional startup command to run.
|
startup_command: Optional startup command to run.
|
||||||
name: Optional session name (auto-generated if omitted).
|
name: Optional session name (auto-generated if omitted).
|
||||||
|
session_id: Optional explicit session UUID.
|
||||||
|
container_user: Optional container user for docker exec.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly created TerminalSession.
|
The newly created TerminalSession.
|
||||||
@@ -177,6 +181,7 @@ class TerminalManager:
|
|||||||
container_id=container_id,
|
container_id=container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name=name,
|
name=name,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
await session.start(startup_command=startup_command)
|
await session.start(startup_command=startup_command)
|
||||||
|
|
||||||
@@ -201,6 +206,7 @@ class TerminalManager:
|
|||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
startup_command: str | None = None,
|
startup_command: str | None = None,
|
||||||
|
container_user: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Get existing session or create a new one.
|
"""Get existing session or create a new one.
|
||||||
|
|
||||||
@@ -243,6 +249,7 @@ class TerminalManager:
|
|||||||
container_id=container_id,
|
container_id=container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name="Session 1",
|
name="Session 1",
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
await session.start(startup_command=startup_command)
|
await session.start(startup_command=startup_command)
|
||||||
self._sessions[key] = session
|
self._sessions[key] = session
|
||||||
@@ -358,6 +365,7 @@ class TerminalManager:
|
|||||||
startup_command: str | None = None,
|
startup_command: str | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
container_user: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Reset a session by killing it and creating a new one.
|
"""Reset a session by killing it and creating a new one.
|
||||||
|
|
||||||
@@ -367,6 +375,7 @@ class TerminalManager:
|
|||||||
startup_command: Optional startup command.
|
startup_command: Optional startup command.
|
||||||
session_id: Specific session to reset. If None, resets the default session.
|
session_id: Specific session to reset. If None, resets the default session.
|
||||||
name: Optional name to preserve for the new session.
|
name: Optional name to preserve for the new session.
|
||||||
|
container_user: Optional container user for docker exec.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly created TerminalSession.
|
The newly created TerminalSession.
|
||||||
@@ -400,6 +409,7 @@ class TerminalManager:
|
|||||||
container_id=container_id,
|
container_id=container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name=old_name or ("Session 1" if target_session_id == "default" else None),
|
name=old_name or ("Session 1" if target_session_id == "default" else None),
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
await new_session.start(startup_command=startup_command)
|
await new_session.start(startup_command=startup_command)
|
||||||
self._sessions[key] = new_session
|
self._sessions[key] = new_session
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ class TerminalSession:
|
|||||||
container_id: str,
|
container_id: str,
|
||||||
startup_command: str | None = None,
|
startup_command: str | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
container_user: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.instance_id = instance_id
|
self.instance_id = instance_id
|
||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
|
self.container_user = container_user
|
||||||
self.startup_command = startup_command
|
self.startup_command = startup_command
|
||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
@@ -105,6 +107,19 @@ class TerminalSession:
|
|||||||
|
|
||||||
async def start(self, startup_command: str | None = None) -> None:
|
async def start(self, startup_command: str | None = None) -> None:
|
||||||
"""Start the docker exec process with a shell using a PTY."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
|
# Build the docker exec command. When the tool manifest declares a
|
||||||
|
# non-root container user, run the shell as that user so terminal
|
||||||
|
# sessions match the privileges of the main container process.
|
||||||
|
exec_cmd = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-it",
|
||||||
|
"-e",
|
||||||
|
"TERM=xterm-256color",
|
||||||
|
]
|
||||||
|
if self.container_user:
|
||||||
|
exec_cmd.extend(["--user", self.container_user])
|
||||||
|
|
||||||
# Create a pseudo-terminal on the host
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, slave_fd = pty.openpty()
|
self._master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
@@ -131,16 +146,9 @@ class TerminalSession:
|
|||||||
shell_cmd = "bash -il"
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
|
exec_cmd.extend([self.container_id, "bash", "-c", shell_cmd])
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
*exec_cmd,
|
||||||
"exec",
|
|
||||||
"-it",
|
|
||||||
"-e",
|
|
||||||
"TERM=xterm-256color",
|
|
||||||
self.container_id,
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
shell_cmd,
|
|
||||||
stdin=slave_fd,
|
stdin=slave_fd,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/tests
|
dir: apps/api/tests
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides shared test infrastructure and fixtures for API endpoint testing
|
Provides shared test infrastructure and fixtures for FastAPI API integration tests.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/.pi-map.index.md
|
index: apps/api/.pi-map.index.md
|
||||||
map: apps/api/.pi-map.md
|
map: apps/api/.pi-map.md
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ dir: apps/api/tests
|
|||||||
index: apps/api/tests/.pi-map.index.md
|
index: apps/api/tests/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides shared test infrastructure and fixtures for API endpoint testing
|
Provides shared test infrastructure and fixtures for FastAPI API integration tests.
|
||||||
## files
|
## files
|
||||||
- conftest.py | Configures shared pytest fixtures for FastAPI testing with async SQLite database, dependency overrides, and authenticated/admin test clients. | exp: func:test_client() → Generator[TestClient, None, None], call:create_async_engine, call:engine.begin, call:conn.run_sync, call:asyncio.run, call:init_db, call:async_sessionmaker, call:patch, call:TestClient, call:app.dependency_overrides.pop, call:engine.dispose, func:init_db(), call:engine.begin, call:conn.run_sync, func:override_get_db_session() → AsyncGenerator[AsyncSession, None], call:async_sessionmaker, func:db_session(test_client) → AsyncGenerator[AsyncSession, None], call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:gen.aclose, call:create_async_engine, call:engine.begin, call:conn.run_sync, call:async_sessionmaker, call:engine.dispose, func:authenticated_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_test_user, call:create_session_cookie, call:test_client.cookies.set, func:create_test_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, func:test_project_and_repo(authenticated_client) → tuple[str, str], call:uuid.uuid4, call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, call:asyncio.run, call:get_user_id, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, call:create_project_and_repo, call:str, raise:RuntimeError, func:get_user_id(), call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, func:create_project_and_repo(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, func:admin_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_admin_user, call:create_session_cookie, call:test_client.cookies.set, func:create_admin_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose | dep: asyncio, os, typing, unittest.mock, pytest, pytest_asyncio, fastapi.testclient, sqlalchemy.ext.asyncio, src.config, src.models.base, src.main, src.auth.dependencies, uuid, src.auth.session, src.models.user.user, src.models.project.project, src.models.project.git_repository, fastapi, sqlalchemy, aiosqlite, src.models, src.auth
|
- conftest.py | Provides shared pytest fixtures for FastAPI integration testing, including test clients, database sessions, authenticated users, and admin users with SQLite in-memory database. | exp: func:test_client() → Generator[TestClient, None, None], call:create_async_engine, call:engine.begin, call:conn.run_sync, call:asyncio.run, call:init_db, call:async_sessionmaker, call:patch, call:TestClient, call:app.dependency_overrides.pop, call:engine.dispose, func:init_db(), call:engine.begin, call:conn.run_sync, func:override_get_db_session() → AsyncGenerator[AsyncSession, None], call:async_sessionmaker, func:db_session(test_client) → AsyncGenerator[AsyncSession, None], call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:gen.aclose, call:create_async_engine, call:engine.begin, call:conn.run_sync, call:async_sessionmaker, call:engine.dispose, func:authenticated_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_test_user, call:create_session_cookie, call:test_client.cookies.set, func:create_test_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, func:test_project_and_repo(authenticated_client) → tuple[str, str], call:uuid.uuid4, call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, call:asyncio.run, call:get_user_id, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, call:create_project_and_repo, call:str, raise:RuntimeError, func:get_user_id(), call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, func:create_project_and_repo(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, func:admin_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_admin_user, call:create_session_cookie, call:test_client.cookies.set, func:create_admin_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose | dep: asyncio, os, typing, unittest.mock, pytest, pytest_asyncio, fastapi.testclient, sqlalchemy.ext.asyncio, src.config, src.models.base, src.main, src.auth.dependencies, uuid, src.auth.session, src.models.user.user, src.models.project.project, src.models.project.git_repository, fastapi, sqlalchemy, aiosqlite
|
||||||
## arch
|
## arch
|
||||||
Pytest plugin architecture with async SQLite test database, dependency injection overrides, and role-based client fixtures (authenticated/admin) for isolated FastAPI integration tests
|
Pytest fixture-based testing architecture with dependency injection overrides, SQLite in-memory database for test isolation, and pre-configured authenticated/admin user states.
|
||||||
## tags
|
## tags
|
||||||
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
|
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/tests/unit
|
dir: apps/api/tests/unit
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Contains unit tests for the API application's core services, utilities, and infrastructure components.
|
Contains unit tests for core API services and utilities, covering database migrations, configuration, Docker operations, Git integration, event handling, file management, health monitoring, manifest compilation, notifications, permissions, SSH keys, and terminal sessions.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/tests/.pi-map.index.md
|
index: apps/api/tests/.pi-map.index.md
|
||||||
map: apps/api/tests/.pi-map.md
|
map: apps/api/tests/.pi-map.md
|
||||||
@@ -32,6 +32,7 @@ map: apps/api/tests/.pi-map.md
|
|||||||
- test_permission_fixer.py
|
- test_permission_fixer.py
|
||||||
- test_readiness_probe.py
|
- test_readiness_probe.py
|
||||||
- test_ssh_keys.py
|
- test_ssh_keys.py
|
||||||
|
- test_terminal_session.py
|
||||||
## links
|
## links
|
||||||
index: apps/api/tests/unit/.pi-map.index.md
|
index: apps/api/tests/unit/.pi-map.index.md
|
||||||
map: apps/api/tests/unit/.pi-map.md
|
map: apps/api/tests/unit/.pi-map.md
|
||||||
|
|||||||
@@ -4,35 +4,36 @@ dir: apps/api/tests/unit
|
|||||||
index: apps/api/tests/unit/.pi-map.index.md
|
index: apps/api/tests/unit/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Contains unit tests for the API application's core services, utilities, and infrastructure components.
|
Contains unit tests for core API services and utilities, covering database migrations, configuration, Docker operations, Git integration, event handling, file management, health monitoring, manifest compilation, notifications, permissions, SSH keys, and terminal sessions.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Swaps two tmux panes between windows, preserving active pane state and layout | dep: tmux
|
- __init__.py | Provides a command to swap the position of two tmux panes within a window or between windows | dep: tmux, client, window, layout, cmd-find, cmd-parse, options
|
||||||
- test_alembic_migrations.py | Unit tests that verify Alembic database migration files are importable, have correct revision identifiers, and declare expected dependencies. | exp: func:test_home_directory_migration_imports_and_rewrites() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable, func:test_merge_migration_resolves_heads() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable, func:test_remove_pi_agent_repo_mount_migration_imports() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable | dep: importlib.util, pathlib, pytest
|
- test_alembic_migrations.py | Unit tests that verify Alembic database migrations are importable, have correct revision identifiers, and declare expected dependencies without requiring a live database. | exp: func:test_home_directory_migration_imports_and_rewrites() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable, func:test_merge_migration_resolves_heads() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable, func:test_remove_pi_agent_repo_mount_migration_imports() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable | dep: importlib.util, pathlib, pytest, importlib
|
||||||
- test_config.py | Unit tests for application configuration settings including database URLs, authentication defaults, and cookie security policies | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database, src.config.Settings, src.database.build_database_url
|
- test_config.py | Unit tests for application configuration settings including database URLs, auth defaults, and cookie policies across environments. | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database
|
||||||
- test_config_profile_resolver.py | Unit tests for config profile resolution including merge helpers, profile inheritance with cycle detection, and git mount normalization | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_directory_not_individual_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path(volumes[0]["source"]).is_dir, call:(Path(volumes[0]["source"]) / "config.json").exists, call:(Path(volumes[0]["source"]) / "nested" / "file.txt").exists, method:test_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path, call:(Path(volumes[0]["source"]) / "z.json").exists, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:(Path(volumes[0]["source"]) / "app.toml").exists, call:Path, method:test_readonly_mount_sets_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, method:test_writable_mount_does_not_set_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, class:TestCheckIncludeCycle | dep: uuid, pathlib, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
|
- test_config_profile_resolver.py | Unit tests for config profile resolution, merging, and application, including inheritance, cycle detection, and mount normalization. | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_directory_not_individual_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path(volumes[0]["source"]).is_dir, call:(Path(volumes[0]["source"]) / "config.json").exists, call:(Path(volumes[0]["source"]) / "nested" / "file.txt").exists, method:test_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path, call:(Path(volumes[0]["source"]) / "z.json").exists, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:(Path(volumes[0]["source"]) / "app.toml").exists, call:Path, method:test_readonly_mount_sets_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, method:test_writable_mount_does_not_set_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, class:TestCheckIncludeCycle | dep: uuid, pathlib, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
|
||||||
- test_docker_build.py | Unit tests for a Docker image build service that verifies command construction, file writing, error handling, and security constraints. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build
|
- test_docker_build.py | Unit tests for a Docker image build service that verifies subprocess invocation, Dockerfile/context file writing, command structure, error handling, and path traversal prevention. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build
|
||||||
- test_docker_service.py | Unit tests for Docker service utilities including container ID/name retrieval and volume sorting by specificity. | exp: class:TestGetContainerId, class:TestGetContainerName, class:TestSortVolumesBySpecificity, method:test_parent_before_child(self) → None, call:sort_volumes_by_specificity, method:test_stable_sort_for_equal_depth(self) → None, call:sort_volumes_by_specificity, method:test_with_type_suffix(self) → None, call:sort_volumes_by_specificity, method:test_empty_list(self) → None, call:sort_volumes_by_specificity, method:test_single_volume(self) → None, call:sort_volumes_by_specificity, method:test_duplicate_target_warning(self, caplog) → None, call:caplog.at_level, call:sort_volumes_by_specificity | dep: unittest.mock, logging, src.services.docker.container, src.services.docker.compose, subprocess
|
- test_docker_service.py | Unit tests for Docker service utilities including container ID/name retrieval and volume sorting by specificity. | exp: class:TestGetContainerId, class:TestGetContainerName, class:TestSortVolumesBySpecificity, method:test_parent_before_child(self) → None, call:sort_volumes_by_specificity, method:test_stable_sort_for_equal_depth(self) → None, call:sort_volumes_by_specificity, method:test_with_type_suffix(self) → None, call:sort_volumes_by_specificity, method:test_empty_list(self) → None, call:sort_volumes_by_specificity, method:test_single_volume(self) → None, call:sort_volumes_by_specificity, method:test_duplicate_target_warning(self, caplog) → None, call:caplog.at_level, call:sort_volumes_by_specificity | dep: unittest.mock, logging, src.services.docker.container, src.services.docker.compose, subprocess
|
||||||
- test_event_bus.py | Unit tests for InstanceEventBus verifying publish/subscribe, exception isolation, unsubscribe, async callback, and bulk unsubscribe functionality. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:sample_payload() → InstanceEventPayload, call:str, call:uuid.uuid4, func:test_publish_delivers_to_all_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, call:len, func:callback_1(payload: InstanceEventPayload) → None, call:received.append, func:callback_2(payload: InstanceEventPayload) → None, call:received.append, func:callback_3(payload: InstanceEventPayload) → None, call:received.append, func:test_subscriber_exception_isolation(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, raise:RuntimeError, func:bad_callback(_payload: InstanceEventPayload) → None, raise:RuntimeError, func:good_callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_unsubscribe_removes_callback(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:unsubscribe, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_publish_to_empty_subscriber_list(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:event_bus.publish, func:test_async_subscriber_supported(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, call:event_bus.subscribe, call:event_bus.publish, func:async_callback(_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, func:test_unsubscribe_all_clears_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.unsubscribe_all, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append | dep: asyncio, uuid, typing, pytest, src.services.instance.event_bus
|
- test_event_bus.py | Unit tests for an instance event bus that validates publish/subscribe, error isolation, async callback support, and unsubscribe functionality. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:sample_payload() → InstanceEventPayload, call:str, call:uuid.uuid4, func:test_publish_delivers_to_all_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, call:len, func:callback_1(payload: InstanceEventPayload) → None, call:received.append, func:callback_2(payload: InstanceEventPayload) → None, call:received.append, func:callback_3(payload: InstanceEventPayload) → None, call:received.append, func:test_subscriber_exception_isolation(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, raise:RuntimeError, func:bad_callback(_payload: InstanceEventPayload) → None, raise:RuntimeError, func:good_callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_unsubscribe_removes_callback(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:unsubscribe, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_publish_to_empty_subscriber_list(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:event_bus.publish, func:test_async_subscriber_supported(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, call:event_bus.subscribe, call:event_bus.publish, func:async_callback(_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, func:test_unsubscribe_all_clears_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.unsubscribe_all, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append | dep: asyncio, uuid, typing, pytest, src.services.instance.event_bus
|
||||||
- test_file_service.py | Unit tests for FileService validating directory listing, file read/write operations, binary file rejection, and path traversal security. | exp: class:TestFileService, method:test_list_directory_empty(self, temp_workspace: Workspace), call:FileService, call:service.list_directory, method:test_list_directory_with_files(self, temp_workspace: Workspace), call:os.makedirs, call:os.path.join, call:open, call:f.write, call:FileService, call:service.list_directory, call:len, method:test_read_file(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:service.read_file, method:test_read_binary_file_rejected(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:pytest.raises, call:service.read_file, method:test_write_file(self, temp_workspace: Workspace), call:FileService, call:service.write_file, call:os.path.exists, call:os.path.join, call:open, call:f.read, method:test_path_escapes_workspace(self, temp_workspace: Workspace), call:FileService, call:pytest.raises, call:service.list_directory, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:Workspace | dep: os, tempfile, pytest, src.models, src.services.shared.file_service, src.models.Workspace, src.services.shared.file_service.FileService
|
- test_file_service.py | Unit tests for FileService validating directory listing, file read/write, binary rejection, and path traversal prevention. | exp: class:TestFileService, method:test_list_directory_empty(self, temp_workspace: Workspace), call:FileService, call:service.list_directory, method:test_list_directory_with_files(self, temp_workspace: Workspace), call:os.makedirs, call:os.path.join, call:open, call:f.write, call:FileService, call:service.list_directory, call:len, method:test_read_file(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:service.read_file, method:test_read_binary_file_rejected(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:FileService, call:pytest.raises, call:service.read_file, method:test_write_file(self, temp_workspace: Workspace), call:FileService, call:service.write_file, call:os.path.exists, call:os.path.join, call:open, call:f.read, method:test_path_escapes_workspace(self, temp_workspace: Workspace), call:FileService, call:pytest.raises, call:service.list_directory, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:Workspace | dep: os, tempfile, pytest, src.models, src.services.shared.file_service, src.models.Workspace, src.services.shared.file_service.FileService
|
||||||
- test_git_operations.py | Unit tests for GitOperations class covering status, commit, history, and branch operations | exp: class:TestGitOperationsStatus, method:test_status_clean(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.status, method:test_status_modified(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.status, method:test_status_untracked(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.status, class:TestGitOperationsCommit, method:test_commit_stages_and_commits(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.commit, call:git.status, call:git.history, method:test_commit_fails_without_changes(self, temp_workspace: Workspace), call:GitOperations, call:pytest.raises, call:asyncio.run, call:git.commit, class:TestGitOperationsHistory, method:test_history_returns_commits(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.history, call:len, method:test_history_filters_by_path(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.commit, call:git.history, call:len, class:TestGitOperationsBranches, method:test_branches_lists_main(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.branches, method:test_checkout_switches_branch(self, temp_workspace: Workspace), call:_run_git, call:GitOperations, call:asyncio.run, call:git.checkout, call:git.status, func:_run_git(*args: str, cwd: str) → None, call:subprocess.run, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:_run_git, call:os.path.join, call:open, call:f.write, call:Workspace | dep: asyncio, os, subprocess, tempfile, pytest, src.models, src.services.git.git_operations, src.models.Workspace, src.services.git.git_operations.GitOperations
|
- test_git_operations.py | Unit tests for GitOperations class covering status, commit, history, and branch operations | exp: class:TestGitOperationsStatus, method:test_status_clean(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.status, method:test_status_modified(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.status, method:test_status_untracked(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.status, class:TestGitOperationsCommit, method:test_commit_stages_and_commits(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.commit, call:git.status, call:git.history, method:test_commit_fails_without_changes(self, temp_workspace: Workspace), call:GitOperations, call:pytest.raises, call:asyncio.run, call:git.commit, class:TestGitOperationsHistory, method:test_history_returns_commits(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.history, call:len, method:test_history_filters_by_path(self, temp_workspace: Workspace), call:open, call:os.path.join, call:f.write, call:GitOperations, call:asyncio.run, call:git.commit, call:git.history, call:len, class:TestGitOperationsBranches, method:test_branches_lists_main(self, temp_workspace: Workspace), call:GitOperations, call:asyncio.run, call:git.branches, method:test_checkout_switches_branch(self, temp_workspace: Workspace), call:_run_git, call:GitOperations, call:asyncio.run, call:git.checkout, call:git.status, func:_run_git(*args: str, cwd: str) → None, call:subprocess.run, func:temp_workspace(), call:tempfile.TemporaryDirectory, call:_run_git, call:os.path.join, call:open, call:f.write, call:Workspace | dep: asyncio, os, subprocess, tempfile, pytest, src.models, src.services.git.git_operations, src.models.Workspace, src.services.git.git_operations.GitOperations
|
||||||
- test_git_service.py | Unit tests for GitService class covering clone, fetch, pull, and branch_exists_remotely operations with mocked subprocess calls. | exp: class:TestGitServiceClone, class:TestGitServiceFetch, class:TestGitServicePull, class:TestGitServiceBranchExistsRemotely, method:test_branch_exists(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely, call:mock_run.assert_called_once_with, method:test_branch_not_exists(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely, method:test_ls_remote_fails(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely | dep: asyncio, unittest.mock, pytest, src.services.git.git_service
|
- test_git_service.py | Unit tests for GitService covering clone, fetch, pull, and branch existence checks via mocked subprocess calls | exp: class:TestGitServiceClone, class:TestGitServiceFetch, class:TestGitServicePull, class:TestGitServiceBranchExistsRemotely, method:test_branch_exists(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely, call:mock_run.assert_called_once_with, method:test_branch_not_exists(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely, method:test_ls_remote_fails(self), call:MagicMock, call:patch, call:GitService.branch_exists_remotely | dep: asyncio, unittest.mock, pytest, src.services.git.git_service
|
||||||
- test_git_url_parser.py | Unit tests for git URL parsing utilities that validate extraction of base repository URLs, clone URL validation, and comprehensive URL parsing across GitHub, GitLab, and Bitbucket formats. | exp: class:TestExtractBaseRepoUrl, method:test_github_tree_url(self), call:extract_base_repo_url, method:test_github_blob_url(self), call:extract_base_repo_url, method:test_github_pull_url(self), call:extract_base_repo_url, method:test_github_issues_url(self), call:extract_base_repo_url, method:test_github_valid_url(self), call:extract_base_repo_url, method:test_github_url_with_query_params(self), call:extract_base_repo_url, method:test_gitlab_tree_url(self), call:extract_base_repo_url, method:test_gitlab_blob_url(self), call:extract_base_repo_url, method:test_gitlab_merge_request_url(self), call:extract_base_repo_url, method:test_gitlab_valid_url(self), call:extract_base_repo_url, method:test_bitbucket_src_url(self), call:extract_base_repo_url, method:test_bitbucket_valid_url(self), call:extract_base_repo_url, method:test_ssh_url(self), call:extract_base_repo_url, method:test_ssh_url_without_git_suffix(self), call:extract_base_repo_url, method:test_invalid_url(self), call:extract_base_repo_url, method:test_empty_url(self), call:extract_base_repo_url, class:TestIsValidCloneUrl, method:test_valid_ssh_url(self), call:is_valid_clone_url, method:test_valid_https_url(self), call:is_valid_clone_url, method:test_browser_url(self), call:is_valid_clone_url, method:test_url_without_git_suffix(self), call:is_valid_clone_url, method:test_invalid_url(self), call:is_valid_clone_url, class:TestParseGitUrl, method:test_valid_git_url(self), call:parse_git_url, method:test_browser_url(self), call:parse_git_url, method:test_invalid_url(self), call:parse_git_url, method:test_empty_url(self), call:parse_git_url, method:test_ssh_url(self), call:parse_git_url | dep: src.utils.git_url_parser, pytest
|
- test_git_url_parser.py | Unit tests for git URL parsing utilities that validate extraction, validation, and parsing of various GitHub, GitLab, and Bitbucket URL formats. | exp: class:TestExtractBaseRepoUrl, method:test_github_tree_url(self), call:extract_base_repo_url, method:test_github_blob_url(self), call:extract_base_repo_url, method:test_github_pull_url(self), call:extract_base_repo_url, method:test_github_issues_url(self), call:extract_base_repo_url, method:test_github_valid_url(self), call:extract_base_repo_url, method:test_github_url_with_query_params(self), call:extract_base_repo_url, method:test_gitlab_tree_url(self), call:extract_base_repo_url, method:test_gitlab_blob_url(self), call:extract_base_repo_url, method:test_gitlab_merge_request_url(self), call:extract_base_repo_url, method:test_gitlab_valid_url(self), call:extract_base_repo_url, method:test_bitbucket_src_url(self), call:extract_base_repo_url, method:test_bitbucket_valid_url(self), call:extract_base_repo_url, method:test_ssh_url(self), call:extract_base_repo_url, method:test_ssh_url_without_git_suffix(self), call:extract_base_repo_url, method:test_invalid_url(self), call:extract_base_repo_url, method:test_empty_url(self), call:extract_base_repo_url, class:TestIsValidCloneUrl, method:test_valid_ssh_url(self), call:is_valid_clone_url, method:test_valid_https_url(self), call:is_valid_clone_url, method:test_browser_url(self), call:is_valid_clone_url, method:test_url_without_git_suffix(self), call:is_valid_clone_url, method:test_invalid_url(self), call:is_valid_clone_url, class:TestParseGitUrl, method:test_valid_git_url(self), call:parse_git_url, method:test_browser_url(self), call:parse_git_url, method:test_invalid_url(self), call:parse_git_url, method:test_empty_url(self), call:parse_git_url, method:test_ssh_url(self), call:parse_git_url | dep: src.utils.git_url_parser, pytest
|
||||||
- test_health_monitor.py | Unit tests for HealthMonitor state-transition logic covering container crash detection, tunnel failure detection, recovery detection, write optimization, and exception resilience. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:health_monitor(event_bus: InstanceEventBus) → HealthMonitor, call:HealthMonitor, func:_create_running_instance(db_session) → ToolInstance, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, func:test_detects_container_crash(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_detects_tunnel_failure(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_detects_recovery(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:db_session.commit, call:HealthSnapshot, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_skips_writes_when_no_state_change(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:HealthSnapshot, call:patch, call:health_monitor._check_instance, call:db_session.execute, call:select(HealthCheck).where, call:len, call:result.scalars().all, func:test_docker_exception_resilience(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:RuntimeError, call:health_monitor._check_instance, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one_or_none, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_monitor_start_stop(health_monitor: HealthMonitor) → None, call:health_monitor.start, call:task.done, call:health_monitor.stop, call:suppress, call:task.cancelled | dep: asyncio, uuid, contextlib, unittest.mock, pytest, sqlalchemy, src.models.system.health_check, src.models.tool.tool_instance, src.models.user.user, src.services.instance.event_bus, src.services.instance.health_monitor
|
- test_health_monitor.py | Unit tests for HealthMonitor state-transition logic covering container crash detection, tunnel failure detection, recovery detection, write deduplication, and exception resilience. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:health_monitor(event_bus: InstanceEventBus) → HealthMonitor, call:HealthMonitor, func:_create_running_instance(db_session) → ToolInstance, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, func:test_detects_container_crash(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_detects_tunnel_failure(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_detects_recovery(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:db_session.commit, call:HealthSnapshot, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_skips_writes_when_no_state_change(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:HealthSnapshot, call:patch, call:health_monitor._check_instance, call:db_session.execute, call:select(HealthCheck).where, call:len, call:result.scalars().all, func:test_docker_exception_resilience(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:RuntimeError, call:health_monitor._check_instance, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one_or_none, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_monitor_start_stop(health_monitor: HealthMonitor) → None, call:health_monitor.start, call:task.done, call:health_monitor.stop, call:suppress, call:task.cancelled | dep: asyncio, uuid, contextlib, unittest.mock, pytest, sqlalchemy, src.models.system.health_check, src.models.tool.tool_instance, src.models.user.user, src.services.instance.event_bus, src.services.instance.health_monitor
|
||||||
- test_home_path_expansion.py | Unit tests for tilde and $HOME expansion in container paths, plus manifest-based home directory resolution | exp: class:TestExpandContainerPath, method:test_tilde_slash_expands(self) → None, call:expand_container_path, method:test_tilde_alone_expands(self) → None, call:expand_container_path, method:test_dollar_home_slash_expands(self) → None, call:expand_container_path, method:test_dollar_home_alone_expands(self) → None, call:expand_container_path, method:test_absolute_path_unchanged(self) → None, call:expand_container_path, method:test_relative_path_unchanged(self) → None, call:expand_container_path, method:test_tilde_in_middle_unchanged(self) → None, call:expand_container_path, method:test_dollar_home_in_middle_unchanged(self) → None, call:expand_container_path, method:test_root_home(self) → None, call:expand_container_path, class:TestGetManifestHomeDir, method:test_with_user_block(self) → None, call:get_manifest_home_dir, method:test_without_user_block(self) → None, call:get_manifest_home_dir, method:test_with_empty_user_name(self) → None, call:get_manifest_home_dir, method:test_with_none_user_name(self) → None, call:get_manifest_home_dir | dep: pytest, src.services.config.config_profile_resolver, src.services.build.manifest_compiler
|
- test_home_path_expansion.py | Unit tests for tilde and $HOME expansion in container paths, and manifest-based home directory resolution. | exp: class:TestExpandContainerPath, method:test_tilde_slash_expands(self) → None, call:expand_container_path, method:test_tilde_alone_expands(self) → None, call:expand_container_path, method:test_dollar_home_slash_expands(self) → None, call:expand_container_path, method:test_dollar_home_alone_expands(self) → None, call:expand_container_path, method:test_absolute_path_unchanged(self) → None, call:expand_container_path, method:test_relative_path_unchanged(self) → None, call:expand_container_path, method:test_tilde_in_middle_unchanged(self) → None, call:expand_container_path, method:test_dollar_home_in_middle_unchanged(self) → None, call:expand_container_path, method:test_root_home(self) → None, call:expand_container_path, class:TestGetManifestHomeDir, method:test_with_user_block(self) → None, call:get_manifest_home_dir, method:test_without_user_block(self) → None, call:get_manifest_home_dir, method:test_with_empty_user_name(self) → None, call:get_manifest_home_dir, method:test_with_none_user_name(self) → None, call:get_manifest_home_dir | dep: pytest, src.services.config.config_profile_resolver, src.services.build.manifest_compiler
|
||||||
- test_instance_service.py | Unit tests for tool instance service functions including compose file modification, repository mount name resolution, profile/git mount stacking, and manifest instance preparation. | exp: class:TestModifyComposeFile, method:test_extra_volumes_expand_home_dir(self, tmp_path), call:compose_path.write_text, call:modify_compose_file, call:str, call:compose_path.read_text, method:test_working_directory_expands_home_dir(self, tmp_path), call:compose_path.write_text, call:modify_compose_file, call:str, call:compose_path.read_text, class:TestGetRepositoryMountName, method:test_prefers_remote_url_name_over_user_provided_name(self), call:MagicMock, call:_get_repository_mount_name, method:test_parses_browser_url_to_repo_name(self), call:MagicMock, call:_get_repository_mount_name, method:test_uses_workspace_path_basename_when_workspace_provided(self), call:MagicMock, call:_get_repository_mount_name, method:test_falls_back_to_repo_name_when_remote_url_missing(self), call:MagicMock, call:_get_repository_mount_name, method:test_falls_back_to_repo_name_for_unparseable_url(self), call:MagicMock, call:_get_repository_mount_name, class:TestStackProfileMountsWithGitMounts, method:test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) → None, call:git_source.mkdir, call:(git_source / "existing.txt").write_text, call:profile_source.mkdir, call:(profile_source / "settings.json").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, call:(git_source / "existing.txt").read_text, call:(git_source / "settings.json").read_text, method:test_descendant_overlap_copies_into_subdirectory(self, tmp_path) → None, call:git_source.mkdir, call:(git_source / "README").write_text, call:profile_source.mkdir, call:(profile_source / "settings.json").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, call:(git_source / "agent" / "settings.json").read_text, call:(git_source / "README").read_text, method:test_non_overlapping_mounts_left_untouched(self, tmp_path) → None, call:git_source.mkdir, call:profile_source.mkdir, call:(profile_source / "config").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, method:test_git_source_file_does_not_consume_profile_mount(self, tmp_path) → None, call:git_source.write_text, call:profile_source.mkdir, call:(profile_source / "settings.json").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, method:test_profile_source_file_copied_into_git_source(self, tmp_path) → None, call:git_source.mkdir, call:profile_source.write_text, call:str, call:_stack_profile_mounts_with_git_mounts, call:(git_source / "settings.json").read_text, class:Result, func:test_prepare_manifest_instance_uses_workspace_path_basename(), call:MagicMock, call:AsyncMock, call:Result, call:prepare_manifest_instance, func:session_get(model, obj_id), func:fake_run(cmd), call:Result | dep: unittest.mock, pytest, src.services.tool.instance_service, subprocess, src.services.tool, pathlib
|
- test_instance_service.py | Unit tests for the tool instance service covering compose file modification, repository mount naming, profile/git mount stacking, and manifest instance preparation. | exp: class:TestModifyComposeFile, method:test_extra_volumes_expand_home_dir(self, tmp_path), call:compose_path.write_text, call:modify_compose_file, call:str, call:compose_path.read_text, method:test_working_directory_expands_home_dir(self, tmp_path), call:compose_path.write_text, call:modify_compose_file, call:str, call:compose_path.read_text, class:TestGetRepositoryMountName, method:test_uses_project_name(self), call:MagicMock, call:_get_repository_mount_name, method:test_slugifies_project_name(self), call:MagicMock, call:_get_repository_mount_name, class:TestStackProfileMountsWithGitMounts, method:test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) → None, call:git_source.mkdir, call:(git_source / "existing.txt").write_text, call:profile_source.mkdir, call:(profile_source / "settings.json").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, call:(git_source / "existing.txt").read_text, call:(git_source / "settings.json").read_text, method:test_descendant_overlap_copies_into_subdirectory(self, tmp_path) → None, call:git_source.mkdir, call:(git_source / "README").write_text, call:profile_source.mkdir, call:(profile_source / "settings.json").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, call:(git_source / "agent" / "settings.json").read_text, call:(git_source / "README").read_text, method:test_non_overlapping_mounts_left_untouched(self, tmp_path) → None, call:git_source.mkdir, call:profile_source.mkdir, call:(profile_source / "config").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, method:test_git_source_file_does_not_consume_profile_mount(self, tmp_path) → None, call:git_source.write_text, call:profile_source.mkdir, call:(profile_source / "settings.json").write_text, call:str, call:_stack_profile_mounts_with_git_mounts, method:test_profile_source_file_copied_into_git_source(self, tmp_path) → None, call:git_source.mkdir, call:profile_source.write_text, call:str, call:_stack_profile_mounts_with_git_mounts, call:(git_source / "settings.json").read_text, class:Result, func:test_prepare_manifest_instance_uses_workspace_path_basename(), call:MagicMock, call:AsyncMock, call:Result, call:prepare_manifest_instance, func:session_get(model, obj_id), func:fake_run(cmd), call:Result | dep: unittest.mock, pytest, src.services.tool.instance_service, subprocess, src.services.tool
|
||||||
- test_lifecycle_hooks.py | Unit tests for lifecycle hook helper functions that derive notification titles and determine whether events should trigger notifications. | exp: class:TestDeriveTitle, method:test_known_event_types(self) → None, call:_derive_title, method:test_unknown_event_type(self) → None, call:_derive_title, class:TestShouldNotify, method:test_error_events_are_notified(self) → None, call:_should_notify, method:test_health_changed_running_is_notified(self) → None, call:_should_notify, method:test_created_started_stopped_restarted_deleted_filtered(self) → None, call:_should_notify, method:test_health_changed_non_running_filtered(self) → None, call:_should_notify | dep: pytest, src.services.instance.lifecycle_hooks
|
- test_lifecycle_hooks.py | Unit tests for lifecycle hook helper functions that derive notification titles and determine whether events should trigger notifications. | exp: class:TestDeriveTitle, method:test_known_event_types(self) → None, call:_derive_title, method:test_unknown_event_type(self) → None, call:_derive_title, class:TestShouldNotify, method:test_error_events_are_notified(self) → None, call:_should_notify, method:test_health_changed_running_is_notified(self) → None, call:_should_notify, method:test_created_started_stopped_restarted_deleted_filtered(self) → None, call:_should_notify, method:test_health_changed_non_running_filtered(self) → None, call:_should_notify | dep: pytest, src.services.instance.lifecycle_hooks
|
||||||
- test_manifest_compiler.py | Unit tests for a manifest compiler that generates Dockerfiles, docker-compose files, and entrypoint scripts from configuration manifests. | exp: class:TestGetManifestHomeDir, method:test_home_directory_in_manifest_wins(self) → None, call:get_manifest_home_dir, method:test_user_name_derives_home(self) → None, call:get_manifest_home_dir, method:test_root_fallback(self) → None, call:get_manifest_home_dir, method:test_empty_home_directory_falls_back(self) → None, call:get_manifest_home_dir, class:TestCompileDockerfileHomeDirectory, method:test_env_home_and_workdir_use_home_directory(self) → None, call:compile_dockerfile, method:test_workspace_symlink_created(self) → None, call:compile_dockerfile, method:test_runtime_workspace_not_baked_into_image(self) → None, call:compile_dockerfile, method:test_runtime_working_dir_overrides_home_workdir(self) → None, call:compile_dockerfile, method:test_working_dir_expands_tilde(self) → None, call:compile_dockerfile, class:TestCompileComposeHomeDirectory, method:test_default_repo_mount_synthesized(self) → None, call:compile_compose, method:test_explicit_repo_mount_preserved(self) → None, call:compile_compose, method:test_workspace_name_substituted_in_mount_target(self) → None, call:compile_compose, method:test_working_dir_expands_home(self) → None, call:compile_compose, class:TestCompileEntrypoint, method:test_entrypoint_creates_home_and_workspace(self) → None, call:compile_entrypoint, method:test_entrypoint_removes_stale_placeholder_directory(self) → None, call:compile_entrypoint, method:test_entrypoint_uses_root_then_sudo_for_workspace_symlink(self) → None, call:compile_entrypoint, call:entrypoint.find, method:test_entrypoint_fixes_mount_owners(self) → None, call:compile_entrypoint, func:test_compile_dockerfile_creates_config_dirs_for_user() → None, call:compile_dockerfile, func:test_compile_dockerfile_no_user_does_not_create_home() → None, call:compile_dockerfile, func:test_compile_dockerfile_uses_user_npm_prefix() → None, call:compile_dockerfile, func:test_compile_dockerfile_starts_as_root_and_drops_privileges() → None, call:compile_dockerfile, call:compile_entrypoint, func:test_compile_compose_runs_as_root() → None, call:compile_compose | dep: pytest, src.services.build.manifest_compiler
|
- test_manifest_compiler.py | Unit tests for a manifest compiler that generates Dockerfiles, docker-compose files, and entrypoint scripts from manifest configurations. | exp: class:TestGetManifestHomeDir, method:test_home_directory_in_manifest_wins(self) → None, call:get_manifest_home_dir, method:test_user_name_derives_home(self) → None, call:get_manifest_home_dir, method:test_root_fallback(self) → None, call:get_manifest_home_dir, method:test_empty_home_directory_falls_back(self) → None, call:get_manifest_home_dir, class:TestCompileDockerfileHomeDirectory, method:test_env_home_and_workdir_use_project_directory(self) → None, call:compile_dockerfile, method:test_project_directory_created(self) → None, call:compile_dockerfile, method:test_runtime_workspace_not_baked_into_image(self) → None, call:compile_dockerfile, method:test_runtime_working_dir_overrides_home_workdir(self) → None, call:compile_dockerfile, method:test_working_dir_expands_tilde(self) → None, call:compile_dockerfile, class:TestCompileComposeHomeDirectory, method:test_default_repo_mount_synthesized(self) → None, call:compile_compose, method:test_explicit_repo_mount_preserved(self) → None, call:compile_compose, method:test_workspace_name_substituted_in_mount_target(self) → None, call:compile_compose, method:test_working_dir_expands_home(self) → None, call:compile_compose, class:TestCompileEntrypoint, method:test_entrypoint_creates_home_and_project_directory(self) → None, call:compile_entrypoint, method:test_entrypoint_removes_stale_placeholder_directory(self) → None, call:compile_entrypoint, method:test_entrypoint_does_not_create_workspace_symlink(self) → None, call:compile_entrypoint, method:test_entrypoint_fixes_mount_owners(self) → None, call:compile_entrypoint, func:test_compile_dockerfile_creates_config_dirs_for_user() → None, call:compile_dockerfile, func:test_compile_dockerfile_no_user_does_not_create_home() → None, call:compile_dockerfile, func:test_compile_dockerfile_uses_user_npm_prefix() → None, call:compile_dockerfile, func:test_compile_dockerfile_starts_as_root_and_drops_privileges() → None, call:compile_dockerfile, call:compile_entrypoint, func:test_compile_compose_does_not_pin_root_user() → None, call:compile_compose, func:test_get_manifest_container_user_returns_name() → None, call:get_manifest_container_user, func:test_get_manifest_container_user_falls_back_to_uid_gid() → None, call:get_manifest_container_user, func:test_get_manifest_container_user_returns_none_without_user() → None, call:get_manifest_container_user | dep: pytest, src.services.build.manifest_compiler
|
||||||
- test_migration_metadata.py | Validates Alembic database migration files by dynamically loading and verifying expected table names and revision chain metadata | exp: func:test_initial_migration_defines_all_core_tables() → None, call:Path(__file__).resolve, call:spec_from_file_location, call:module_from_spec, call:spec.loader.exec_module, func:test_refresh_tokens_migration_has_expected_revision_chain() → None, call:Path(__file__).resolve, call:spec_from_file_location, call:module_from_spec, call:spec.loader.exec_module | dep: pytest, importlib.util, pathlib
|
- test_migration_metadata.py | Validates Alembic migration files by dynamically importing them and verifying table definitions and revision chain metadata. | exp: func:test_initial_migration_defines_all_core_tables() → None, call:Path(__file__).resolve, call:spec_from_file_location, call:module_from_spec, call:spec.loader.exec_module, func:test_refresh_tokens_migration_has_expected_revision_chain() → None, call:Path(__file__).resolve, call:spec_from_file_location, call:module_from_spec, call:spec.loader.exec_module | dep: pytest, importlib.util, pathlib, pathlib.Path
|
||||||
- test_monitoring_models.py | Unit tests for monitoring models (InstanceEvent and HealthCheck) verifying creation, persistence, and querying capabilities with database migration compatibility. | exp: func:test_instance_event_creation(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:InstanceEvent, call:db_session.refresh, call:isinstance, func:test_health_check_creation(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:HealthCheck, call:db_session.refresh, call:isinstance, func:test_instance_event_query_by_instance(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:InstanceEvent, call:db_session.execute, call:select(InstanceEvent).where, call:result.scalar_one | dep: uuid, datetime, pytest, sqlalchemy, src.models.system.health_check, src.models.system.instance_event, src.models.tool.tool_instance, src.models.user.user
|
- test_monitoring_models.py | Unit tests verifying creation, persistence, and querying of monitoring models (InstanceEvent and HealthCheck) with database migration compatibility. | exp: func:test_instance_event_creation(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:InstanceEvent, call:db_session.refresh, call:isinstance, func:test_health_check_creation(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:HealthCheck, call:db_session.refresh, call:isinstance, func:test_instance_event_query_by_instance(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:InstanceEvent, call:db_session.execute, call:select(InstanceEvent).where, call:result.scalar_one | dep: uuid, datetime, pytest, sqlalchemy, src.models.system.health_check, src.models.system.instance_event, src.models.tool.tool_instance, src.models.user.user
|
||||||
- test_notification_service.py | Unit tests for NotificationService covering CRUD operations, filtering, pagination, and ownership validation for user notifications. | exp: func:notification_service() → NotificationService, call:NotificationService, func:user_a(db_session: AsyncSession) → User, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, func:user_b(db_session: AsyncSession) → User, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, func:test_create_notification(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:uuid.uuid4, func:test_list_notifications_orders_by_created_at_desc(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:datetime.now, call:timedelta, call:db_session.commit, call:db_session.refresh, call:notification_service.list_notifications, func:test_list_notifications_excludes_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:notification_service.list_notifications, func:test_list_notifications_unread_only(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.mark_read, call:notification_service.list_notifications, func:test_get_unread_count(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_read, call:notification_service.get_unread_count, func:test_mark_read_sets_read_at(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.mark_read, func:test_mark_all_read_affects_all_unread(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_all_read, call:notification_service.get_unread_count, func:test_dismiss_sets_dismissed_at(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:db_session.execute, call:select(Notification).where, call:result.scalar_one, func:test_mark_read_wrong_owner_raises(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:notification_service.create_notification, call:pytest.raises, call:notification_service.mark_read, func:test_dismiss_wrong_owner_raises(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:notification_service.create_notification, call:pytest.raises, call:notification_service.dismiss, func:test_list_notifications_mute_categories(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.list_notifications, func:test_get_unread_count_excludes_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:notification_service.get_unread_count, func:test_dismiss_all_affects_all_non_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.dismiss_all, call:notification_service.list_notifications, func:test_dismiss_all_affects_only_caller(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:range, call:notification_service.create_notification, call:notification_service.dismiss_all, call:notification_service.list_notifications, func:test_mark_all_read_affects_only_caller(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_all_read, call:notification_service.get_unread_count | dep: uuid, datetime, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.models.system.notification, src.models.user.user, src.services.shared.notification_service
|
- test_notification_service.py | Unit tests for NotificationService covering CRUD operations, filtering, ownership validation, and bulk actions on user notifications. | exp: func:notification_service() → NotificationService, call:NotificationService, func:user_a(db_session: AsyncSession) → User, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, func:user_b(db_session: AsyncSession) → User, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, func:test_create_notification(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:uuid.uuid4, func:test_list_notifications_orders_by_created_at_desc(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:datetime.now, call:timedelta, call:db_session.commit, call:db_session.refresh, call:notification_service.list_notifications, func:test_list_notifications_excludes_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:notification_service.list_notifications, func:test_list_notifications_unread_only(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.mark_read, call:notification_service.list_notifications, func:test_get_unread_count(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_read, call:notification_service.get_unread_count, func:test_mark_read_sets_read_at(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.mark_read, func:test_mark_all_read_affects_all_unread(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_all_read, call:notification_service.get_unread_count, func:test_dismiss_sets_dismissed_at(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:db_session.execute, call:select(Notification).where, call:result.scalar_one, func:test_mark_read_wrong_owner_raises(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:notification_service.create_notification, call:pytest.raises, call:notification_service.mark_read, func:test_dismiss_wrong_owner_raises(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:notification_service.create_notification, call:pytest.raises, call:notification_service.dismiss, func:test_list_notifications_mute_categories(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.list_notifications, func:test_get_unread_count_excludes_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:notification_service.get_unread_count, func:test_dismiss_all_affects_all_non_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.dismiss_all, call:notification_service.list_notifications, func:test_dismiss_all_affects_only_caller(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:range, call:notification_service.create_notification, call:notification_service.dismiss_all, call:notification_service.list_notifications, func:test_mark_all_read_affects_only_caller(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_all_read, call:notification_service.get_unread_count | dep: uuid, datetime, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.models.system.notification, src.models.user.user, src.services.shared.notification_service, Notification, User, NotificationService
|
||||||
- test_notifications_api_routes.py | Tests that FastAPI notification routes are ordered correctly so bulk DELETE /notifications matches before parameterized DELETE /notifications/{id} | exp: func:test_delete_notifications_route_order() → None, call:FastAPI, call:app.include_router, call:TestClient, call:client.delete | dep: fastapi, fastapi.testclient, src.api.system.notifications
|
- test_notifications_api_routes.py | Tests FastAPI route ordering to ensure DELETE /notifications matches the bulk clear endpoint before the parameterized DELETE /notifications/{id} endpoint. | exp: func:test_delete_notifications_route_order() → None, call:FastAPI, call:app.include_router, call:TestClient, call:client.delete | dep: fastapi, fastapi.testclient, src.api.system.notifications
|
||||||
- test_permission_fixer.py | Unit tests for a Docker container permission fixer service that adjusts mount and SSH permissions via container exec commands. | exp: class:TestApplyMountPermissions, class:TestRunInContainer, class:TestApplySshPermissions, class:TestCheckRootUserAvailable | dep: unittest.mock, pytest, src.services.shared.permission_fixer, subprocess
|
- test_permission_fixer.py | Unit tests for a Docker container permission fixer that applies chown/chmod/file_mode to mounts and SSH directories. | exp: class:TestApplyMountPermissions, class:TestRunInContainer, class:TestApplySshPermissions, class:TestCheckRootUserAvailable | dep: unittest.mock, pytest, src.services.shared.permission_fixer, subprocess
|
||||||
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands with retry logic and timeout handling | exp: class:TestExecuteProbe, class:TestIntegrationScenarios | dep: unittest.mock, src.services.shared.readiness_probe, subprocess
|
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands with retry logic and timeout handling | exp: class:TestExecuteProbe, class:TestIntegrationScenarios | dep: unittest.mock, src.services.shared.readiness_probe, subprocess
|
||||||
- test_ssh_keys.py | Unit tests for SSH key preparation functionality including file creation, permissions, ownership, and error handling. | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.shared.ssh_keys
|
- test_ssh_keys.py | Unit tests for SSH key file preparation functionality including file creation, permissions, ownership, and error handling. | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.shared.ssh_keys
|
||||||
|
- test_terminal_session.py | Unit tests for TerminalSession verifying docker exec command construction with/without container user flag | exp: func:test_start_passes_container_user_to_docker_exec() → None, call:TerminalSession, call:str, call:uuid.uuid4, call:patch, call:AsyncMock, call:session.start, call:args.index, func:test_start_omits_user_when_not_configured() → None, call:TerminalSession, call:str, call:uuid.uuid4, call:patch, call:AsyncMock, call:session.start | dep: uuid, unittest.mock, pytest, src.services.terminal.terminal_session, pty, asyncio, os
|
||||||
## arch
|
## arch
|
||||||
Standard Python unittest/pytest pattern with isolated test modules per service, heavy use of mocking for external dependencies (Docker, Git, subprocess), and tests covering security constraints, error handling, and state transitions across asynchronous and synchronous operations.
|
Follows pytest-based unit testing architecture with heavy use of mocking (subprocess, filesystem, database) to test services in isolation; tests are organized by service/module with descriptive naming; employs parametrization and fixture-based setup for testing edge cases, error conditions, and path traversal prevention across infrastructure and security-critical components.
|
||||||
## tags
|
## tags
|
||||||
test, url, call:, git, call:notification, home, mounts, mount
|
test, url, call:, git, call:notification, mounts, home, merge
|
||||||
## symbols
|
## symbols
|
||||||
- TestMergeFunctions
|
- TestMergeFunctions
|
||||||
- TestResolveProfile
|
- TestResolveProfile
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from src.services.build.manifest_compiler import (
|
|||||||
compile_compose,
|
compile_compose,
|
||||||
compile_dockerfile,
|
compile_dockerfile,
|
||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
|
get_manifest_container_user,
|
||||||
get_manifest_home_dir,
|
get_manifest_home_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -273,13 +274,19 @@ def test_compile_dockerfile_starts_as_root_and_drops_privileges() -> None:
|
|||||||
entrypoint = compile_entrypoint(manifest)
|
entrypoint = compile_entrypoint(manifest)
|
||||||
|
|
||||||
assert "USER dev" not in dockerfile
|
assert "USER dev" not in dockerfile
|
||||||
assert 'exec runuser -u dev -- /bin/bash -il' in entrypoint
|
assert "exec runuser -u dev -- /bin/bash -il" in entrypoint
|
||||||
assert 'exec runuser -u dev -- "$@"' in entrypoint
|
assert 'exec runuser -u dev -- "$@"' in entrypoint
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_compile_compose_runs_as_root() -> None:
|
def test_compile_compose_does_not_pin_root_user() -> None:
|
||||||
"""The compose service must start as root so the entrypoint can fix ownership."""
|
"""The compose service must not override the user to root.
|
||||||
|
|
||||||
|
The Dockerfile intentionally omits USER so the entrypoint starts as root,
|
||||||
|
fixes mount ownership, and drops privileges internally. Setting a
|
||||||
|
compose-level user would pin docker exec sessions to root even after the
|
||||||
|
entrypoint drops privileges.
|
||||||
|
"""
|
||||||
manifest = {
|
manifest = {
|
||||||
"base_image": "ubuntu:24.04",
|
"base_image": "ubuntu:24.04",
|
||||||
"interface_type": "terminal",
|
"interface_type": "terminal",
|
||||||
@@ -296,7 +303,32 @@ def test_compile_compose_runs_as_root() -> None:
|
|||||||
}
|
}
|
||||||
compose = compile_compose(manifest, variables)
|
compose = compile_compose(manifest, variables)
|
||||||
|
|
||||||
assert "user: 0:0" in compose
|
assert "user:" not in compose
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_manifest_container_user_returns_name() -> None:
|
||||||
|
"""The container user helper returns the manifest user name."""
|
||||||
|
manifest = {
|
||||||
|
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||||
|
}
|
||||||
|
assert get_manifest_container_user(manifest) == "dev"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_manifest_container_user_falls_back_to_uid_gid() -> None:
|
||||||
|
"""When the user name is missing, return uid:gid."""
|
||||||
|
manifest = {
|
||||||
|
"user": {"uid": 1000, "gid": 1000},
|
||||||
|
}
|
||||||
|
assert get_manifest_container_user(manifest) == "1000:1000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_manifest_container_user_returns_none_without_user() -> None:
|
||||||
|
"""When no user is declared, return None."""
|
||||||
|
manifest = {"base_image": "ubuntu:24.04"}
|
||||||
|
assert get_manifest_container_user(manifest) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -314,7 +346,7 @@ class TestCompileEntrypoint:
|
|||||||
|
|
||||||
assert 'mkdir -p "$HOME_DIR"' in entrypoint
|
assert 'mkdir -p "$HOME_DIR"' in entrypoint
|
||||||
assert 'mkdir -p "$WORKSPACE_TARGET"' in entrypoint
|
assert 'mkdir -p "$WORKSPACE_TARGET"' in entrypoint
|
||||||
assert 'ln -sfn' not in entrypoint
|
assert "ln -sfn" not in entrypoint
|
||||||
assert 'WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"' in entrypoint
|
assert 'WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"' in entrypoint
|
||||||
|
|
||||||
def test_entrypoint_removes_stale_placeholder_directory(self) -> None:
|
def test_entrypoint_removes_stale_placeholder_directory(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Unit tests for TerminalSession docker exec invocation."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.terminal.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_passes_container_user_to_docker_exec() -> None:
|
||||||
|
"""When container_user is set, docker exec receives --user <user>."""
|
||||||
|
session = TerminalSession(
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
|
instance_id=uuid.uuid4(),
|
||||||
|
container_id="container-123",
|
||||||
|
container_user="dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.terminal.terminal_session.pty.openpty",
|
||||||
|
return_value=(1, 2),
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_exec:
|
||||||
|
with patch("src.services.terminal.terminal_session.os.close"):
|
||||||
|
await session.start()
|
||||||
|
|
||||||
|
args, _kwargs = mock_exec.call_args
|
||||||
|
assert "docker" in args
|
||||||
|
assert "exec" in args
|
||||||
|
assert "--user" in args
|
||||||
|
user_index = args.index("--user")
|
||||||
|
assert args[user_index + 1] == "dev"
|
||||||
|
assert "container-123" in args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_omits_user_when_not_configured() -> None:
|
||||||
|
"""Without container_user, docker exec does not receive --user."""
|
||||||
|
session = TerminalSession(
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
|
instance_id=uuid.uuid4(),
|
||||||
|
container_id="container-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.terminal.terminal_session.pty.openpty",
|
||||||
|
return_value=(1, 2),
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_exec:
|
||||||
|
with patch("src.services.terminal.terminal_session.os.close"):
|
||||||
|
await session.start()
|
||||||
|
|
||||||
|
args, _kwargs = mock_exec.call_args
|
||||||
|
assert "--user" not in args
|
||||||
@@ -15,11 +15,19 @@ After implementing configurable tool container home directories, new `pi-agent`
|
|||||||
|
|
||||||
## Fix
|
## Fix
|
||||||
|
|
||||||
1. Add an Alembic data migration that updates the built-in `pi-agent` manifest:
|
1. Remove the compose-level `user: 0:0` override from `manifest_compiler.py`. The
|
||||||
|
Dockerfile intentionally omits `USER` so the entrypoint can start as root,
|
||||||
|
fix mount ownership, and drop privileges to the container user internally.
|
||||||
|
Pinning `user: 0:0` in the compose service forces `docker exec` sessions to
|
||||||
|
run as root even after the entrypoint drops privileges.
|
||||||
|
2. Pass the manifest-declared container user into terminal sessions so
|
||||||
|
`docker exec` is invoked with `--user <user>`. This makes WebSocket terminal
|
||||||
|
sessions run as the same non-root user as the main container process.
|
||||||
|
3. Add an Alembic data migration that updates the built-in `pi-agent` manifest:
|
||||||
- Change the repo mount target to `~/{{WORKSPACE_NAME}}`.
|
- Change the repo mount target to `~/{{WORKSPACE_NAME}}`.
|
||||||
- Keep `runtime.working_dir` as `/workspace` (the compatibility symlink).
|
- Keep `runtime.working_dir` as `/workspace` (the compatibility symlink).
|
||||||
- Update the startup script to chown the real mount path (`$HOME/$WORKSPACE_NAME`).
|
- Update the startup script to chown the real mount path (`$HOME/$WORKSPACE_NAME`).
|
||||||
2. Update `manifest_compiler.py`:
|
4. Update `manifest_compiler.py`:
|
||||||
- Substitute `{{WORKSPACE_NAME}}` in mount targets in `compile_compose`.
|
- Substitute `{{WORKSPACE_NAME}}` in mount targets in `compile_compose`.
|
||||||
- Pass `WORKSPACE_NAME` as a container environment variable.
|
- Pass `WORKSPACE_NAME` as a container environment variable.
|
||||||
- Generate the entrypoint symlink from the runtime `WORKSPACE_NAME` environment variable.
|
- Generate the entrypoint symlink from the runtime `WORKSPACE_NAME` environment variable.
|
||||||
@@ -28,25 +36,30 @@ After implementing configurable tool container home directories, new `pi-agent`
|
|||||||
- Start the container as root and drop privileges to the container user inside the entrypoint via `su`.
|
- Start the container as root and drop privileges to the container user inside the entrypoint via `su`.
|
||||||
- Do not create mount target directories or the `/workspace` symlink in the image when they depend on the runtime `{{WORKSPACE_NAME}}` placeholder.
|
- Do not create mount target directories or the `/workspace` symlink in the image when they depend on the runtime `{{WORKSPACE_NAME}}` placeholder.
|
||||||
- Remove any stale literal `{{WORKSPACE_NAME}}` directory left over from older images at container startup.
|
- Remove any stale literal `{{WORKSPACE_NAME}}` directory left over from older images at container startup.
|
||||||
3. Update `instance_service.py` to pass `REPO_NAME` and `WORKSPACE_NAME` into manifest compilation.
|
5. Update `instance_service.py` to pass `REPO_NAME` and `WORKSPACE_NAME` into manifest compilation.
|
||||||
4. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
|
6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
|
||||||
5. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
|
7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
|
||||||
6. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
|
8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
|
||||||
7. Update unit tests for the new behavior.
|
9. Update unit tests for the new behavior.
|
||||||
|
|
||||||
## Affected files
|
## Affected files
|
||||||
|
|
||||||
- `apps/api/alembic/versions/2026_06_14_182955_fix_pi_agent_home_directory_mount.py`
|
- `apps/api/alembic/versions/2026_06_14_182955_fix_pi_agent_home_directory_mount.py`
|
||||||
- `apps/api/alembic/versions/2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py`
|
- `apps/api/alembic/versions/2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py`
|
||||||
- `apps/api/src/services/build/manifest_compiler.py`
|
- `apps/api/src/services/build/manifest_compiler.py`
|
||||||
|
- `apps/api/src/services/terminal/terminal_session.py`
|
||||||
|
- `apps/api/src/services/terminal/terminal_manager.py`
|
||||||
|
- `apps/api/src/api/system/terminal.py`
|
||||||
- `apps/api/src/services/shared/workspace_manager.py`
|
- `apps/api/src/services/shared/workspace_manager.py`
|
||||||
- `apps/api/src/services/tool/instance_service.py`
|
- `apps/api/src/services/tool/instance_service.py`
|
||||||
- `apps/api/tests/unit/test_manifest_compiler.py`
|
- `apps/api/tests/unit/test_manifest_compiler.py`
|
||||||
|
- `apps/api/tests/unit/test_terminal_session.py`
|
||||||
- `apps/api/tests/unit/test_instance_service.py`
|
- `apps/api/tests/unit/test_instance_service.py`
|
||||||
- `apps/api/tests/unit/test_alembic_migrations.py`
|
- `apps/api/tests/unit/test_alembic_migrations.py`
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- `pytest apps/api/tests/unit/test_manifest_compiler.py`
|
- `pytest apps/api/tests/unit/test_manifest_compiler.py`
|
||||||
|
- `pytest apps/api/tests/unit/test_terminal_session.py`
|
||||||
- `pytest apps/api/tests/unit/test_alembic_migrations.py`
|
- `pytest apps/api/tests/unit/test_alembic_migrations.py`
|
||||||
- `ruff`, `mypy`, `npm run typecheck`, `npm run lint`
|
- `ruff`, `mypy`, `npm run typecheck`, `npm run lint`
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
- [x] Remove explicit repo mount from pi-agent manifest; synthesize mount in compile_compose
|
- [x] Remove explicit repo mount from pi-agent manifest; synthesize mount in compile_compose
|
||||||
- [x] Add _get_repository_mount_name() helper to derive workspace name from remote URL
|
- [x] Add _get_repository_mount_name() helper to derive workspace name from remote URL
|
||||||
- [x] Switch workspace storage layout to /data/working-copies/{workspace_id}/{repo_name}/
|
- [x] Switch workspace storage layout to /data/working-copies/{workspace_id}/{repo_name}/
|
||||||
- [x] Update unit tests
|
- [x] Update unit tests for workspace/home-directory migration
|
||||||
- [x] Run quality gates (pytest unit, ruff, mypy)
|
- [x] Run quality gates for workspace/home-directory migration
|
||||||
|
- [x] Remove compose-level `user: 0:0` override so entrypoint can drop privileges
|
||||||
|
- [x] Pass manifest-declared container user to terminal sessions via `docker exec --user`
|
||||||
|
- [x] Update unit tests for container user/terminal changes
|
||||||
|
- [ ] Run quality gates for container user/terminal changes
|
||||||
- [ ] Commit and push
|
- [ ] Commit and push
|
||||||
|
|||||||
Reference in New Issue
Block a user