Compare commits

..

1 Commits

Author SHA1 Message Date
Developer 346b32236a debug: synthesize wheel events for mobile terminal touch scrolling
Replace manual scrollLines/SGR injection with synthetic WheelEvent
instances dispatched into xterm.js's .xterm-viewport. Keep the debug
overlay so we can verify whether the gesture is recognized and whether
the wheel event reaches xterm.js.

Quality gates: typecheck, lint clean.
2026-06-13 21:04:50 +00:00
717 changed files with 6290 additions and 13602 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides. Trust boundary: index routes, map orients, source decides.
## role ## role
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling. Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
## parent ## parent
- -
## children ## children
+2 -2
View File
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides. Trust boundary: index routes, map orients, source decides.
## role ## role
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling. Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
## files ## files
- .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment - .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment
- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git - .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git
@@ -31,7 +31,7 @@ Infrastructure and deployment configuration for a self-hosted project management
- progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases - progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases
- swap-pane | Empty file with no functionality - swap-pane | Empty file with no functionality
## arch ## arch
Containerized microservices architecture using Docker Compose with PostgreSQL and Redis data layers, Traefik reverse proxy for TLS/ingress, multi-stage builds for Node.js frontend and Python backend, and environment-driven configuration management. Docker Compose-based microservices architecture with frontend/backend separation, PostgreSQL/Redis data layer, Traefik reverse proxy integration, and environment-driven configuration management following twelve-factor app principles.
## tags ## tags
docker, redis, git, application, postgresql, compose, traefik, project docker, redis, git, application, postgresql, compose, traefik, project
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps dir: apps
## role ## role
Container for deployable application entry points and top-level configurations in the project. Contains the main deployable application modules or entry points for the project.
## parent ## parent
index: ./.pi-map.index.md index: ./.pi-map.index.md
map: ./.pi-map.md map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps
index: apps/.pi-map.index.md index: apps/.pi-map.index.md
## role ## role
Container for deployable application entry points and top-level configurations in the project. Contains the main deployable application modules or entry points for the project.
## files ## files
## arch ## arch
Monorepo-style directory structure housing independently runnable applications that share common libraries or modules, typically with per-app configuration, dependencies, and build targets. Modular monolith or microservices architecture with separate application boundaries, each potentially having its own configuration, dependencies, and lifecycle.
## tags ## tags
- -
## symbols ## symbols
+1 -7
View File
@@ -2,23 +2,17 @@
dir: apps/api dir: apps/api
## role ## role
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools by orchestrating Docker instances for remote development environments. Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
## 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 - apps/api/.pytest_cache
index: apps/api/.pytest_cache/.pi-map.index.md index: apps/api/.pytest_cache/.pi-map.index.md
map: apps/api/.pytest_cache/.pi-map.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/.venv-test - apps/api/.venv-test
index: apps/api/.venv-test/.pi-map.index.md index: apps/api/.venv-test/.pi-map.index.md
map: apps/api/.venv-test/.pi-map.md map: apps/api/.venv-test/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api
index: apps/api/.pi-map.index.md index: apps/api/.pi-map.index.md
## role ## role
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools by orchestrating Docker instances for remote development environments. Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
## files ## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid copying unnecessary files into containers. | dep: Docker - .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid copying unnecessary files into containers. | dep: Docker
- Dockerfile | Multi-stage Docker build for a Python application with Docker socket access, Cloudflare tunneling, and database dependency waiting | 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 application with Docker socket access, Cloudflare tunneling, and database dependency waiting | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
@@ -14,7 +14,7 @@ Self-hosted FastAPI backend API that manages projects, git repositories, and dev
- uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages - uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages
- wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep - wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep
## arch ## arch
Async Python backend using FastAPI with PostgreSQL (asyncpg), Alembic migrations, uv package management, multi-stage Docker builds with Docker-in-Docker socket access, and Cloudflare tunneling for secure external connectivity. Async Python/FastAPI with PostgreSQL (Alembic migrations), multi-stage Docker deployment with Cloudflare tunneling, uv package management, and containerized service orchestration.
## tags ## tags
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/alembic dir: apps/api/alembic
## role ## role
Database migration infrastructure for the API application, providing version-controlled schema changes with async SQLAlchemy support. Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
## 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
+2 -2
View File
@@ -4,12 +4,12 @@ dir: apps/api/alembic
index: apps/api/alembic/.pi-map.index.md index: apps/api/alembic/.pi-map.index.md
## role ## role
Database migration infrastructure for the API application, providing version-controlled schema changes with async SQLAlchemy support. Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
## files ## files
- env.py | Configures Alembic database migration environment with async SQLAlchemy support for a project. | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio - env.py | Configures Alembic database migration environment with async SQLAlchemy support for a project. | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQLAlchemy schema migrations | dep: alembic, sqlalchemy - script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQLAlchemy schema migrations | dep: alembic, sqlalchemy
## arch ## arch
Alembic migration framework with Mako templating for generating migration scripts, configured for async SQLAlchemy operations. Alembic migration framework with Mako templating for generating revision scripts, async SQLAlchemy engine configuration, and autogenerate capabilities for schema change tracking.
## tags ## tags
migrations, run, sqlalchemy, async, alembic, call:context.configure, call:context.begin, transaction migrations, run, sqlalchemy, async, alembic, call:context.configure, call:context.begin, transaction
## symbols ## symbols
+2 -10
View File
@@ -2,14 +2,12 @@
dir: apps/api/alembic/versions dir: apps/api/alembic/versions
## role ## role
Database schema version control and migration management for the API application, tracking incremental schema changes from initial tables through advanced features like workspaces, manifests, and monitoring. Manages incremental database schema evolution for the API application using Alembic migrations, tracking all table creations, column additions, relationship changes, and data transformations over the project's lifecycle.
## parent ## parent
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
## children ## children
- apps/api/alembic/versions/.ruff_cache -
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
map: apps/api/alembic/versions/.ruff_cache/.pi-map.md
## files ## files
- 0001_initial_schema.py - 0001_initial_schema.py
- 0002_refresh_tokens.py - 0002_refresh_tokens.py
@@ -50,9 +48,6 @@ map: apps/api/alembic/.pi-map.md
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py - 2026_05_29_remove_ssh_keys_mount_from_manifest.py
- 2026_06_01_add_workspaces.py - 2026_06_01_add_workspaces.py
- 2026_06_13_make_clone_mode_nullable.py - 2026_06_13_make_clone_mode_nullable.py
- 2026_06_14_104415_add_tool_type_home_directory.py
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
- 398082499c30_add_tool_config_fields.py - 398082499c30_add_tool_config_fields.py
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py - 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py - 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
@@ -60,7 +55,6 @@ map: apps/api/alembic/.pi-map.md
- 8ed7dd80973d_create_config_folders_table.py - 8ed7dd80973d_create_config_folders_table.py
- af8512103d67_add_tool_type_fields.py - af8512103d67_add_tool_type_fields.py
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py - f3d2dc90ba3a_merge_single_interface_and_clone_mode.py
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py
## links ## links
index: apps/api/alembic/versions/.pi-map.index.md index: apps/api/alembic/versions/.pi-map.index.md
map: apps/api/alembic/versions/.pi-map.md map: apps/api/alembic/versions/.pi-map.md
@@ -71,7 +65,5 @@ map: apps/api/alembic/versions/.pi-map.md
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
- change versions config - change versions config
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
- explore versions subdirectories
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
+7 -13
View File
@@ -4,7 +4,7 @@ dir: apps/api/alembic/versions
index: apps/api/alembic/versions/.pi-map.index.md index: apps/api/alembic/versions/.pi-map.index.md
## role ## role
Database schema version control and migration management for the API application, tracking incremental schema changes from initial tables through advanced features like workspaces, manifests, and monitoring. Manages incremental database schema evolution for the API application using Alembic migrations, tracking all table creations, column additions, relationship changes, and data transformations over the project's lifecycle.
## files ## files
- 0001_initial_schema.py | Defines the initial database schema migration creating five tables (users, ssh_keys, projects, git_repositories, user_configs) with relationships, indexes, and constraints using Alembic. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect - 0001_initial_schema.py | Defines the initial database schema migration creating five tables (users, ssh_keys, projects, git_repositories, user_configs) with relationships, indexes, and constraints using Alembic. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication token management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql - 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication token management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
@@ -24,7 +24,7 @@ Database schema version control and migration management for the API application
- 0014_merge_heads.py | Alembic merge migration that reconciles two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic - 0014_merge_heads.py | Alembic merge migration that reconciles two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
- 0015_single_interface.py | Alembic database migration that replaces a JSON array `interfaces` column with `interface_type` string and `requires_port` boolean columns in the `tool_types` table, with dialect-specific data migration for PostgreSQL and SQLite. | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql (dialect) - 0015_single_interface.py | Alembic database migration that replaces a JSON array `interfaces` column with `interface_type` string and `requires_port` boolean columns in the `tool_types` table, with dialect-specific data migration for PostgreSQL and SQLite. | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql (dialect)
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds a nullable UUID foreign key column `ssh_key_id` to the `config_profiles` table referencing `ssh_keys.id` with SET NULL on delete | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy - 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds a nullable UUID foreign key column `ssh_key_id` to the `config_profiles` table referencing `ssh_keys.id` with SET NULL on delete | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a new "pi-agent" tool type with terminal-based Docker environment for the Pi coding agent | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy - 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a "pi-agent" terminal-based coding tool type to a tool_types table with Docker configuration templates | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
- 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories table and clone_mode/branch columns to tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql - 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories table and clone_mode/branch columns to tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 2026_05_23_remove_is_builtin.py | Alembic database migration to remove the `is_builtin` column from the `tool_types` table | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy - 2026_05_23_remove_is_builtin.py | Alembic database migration to remove the `is_builtin` column from the `tool_types` table | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy - 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
@@ -33,7 +33,7 @@ Database schema version control and migration management for the API application
- 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories table to support external repositories and expands alembic_version version_num column to 64 characters. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy - 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories table to support external repositories and expands alembic_version version_num column to 64 characters. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
- 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health checks | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy - 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health checks | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with tracking columns and foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy - 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with tracking columns and foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest support to existing tool_types and tool_instances tables, and seeds initial data with a base Ubuntu image and pi-agent manifest while migrating the legacy pi-agent from Dockerfile templates to the new manifest system. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy - 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest-related columns to tool_types and tool_instances, and migrates the pi-agent tool from Dockerfile-based to manifest-based definitions with seed data. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
- 2026_05_28_drop_tool_configs_and_config_folders.py | Alembic database migration that drops `tool_configs` and `config_folders` tables with conditional existence checks and full downgrade recreation | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy - 2026_05_28_drop_tool_configs_and_config_folders.py | Alembic database migration that drops `tool_configs` and `config_folders` tables with conditional existence checks and full downgrade recreation | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
- 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed tracking and optimized querying indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy - 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed tracking and optimized querying indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON column named ssh_key_ids to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy - 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON column named ssh_key_ids to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
@@ -44,22 +44,18 @@ Database schema version control and migration management for the API application
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database tool_types records and on-disk instance compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy, pathlib.Path, information_schema - 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database tool_types records and on-disk instance compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy, pathlib.Path, information_schema
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (or restores) the ssh_keys mount from a JSON manifest stored in the tool_definition_manifests table for the pi-agent tool definition. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy - 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (or restores) the ssh_keys mount from a JSON manifest stored in the tool_definition_manifests table for the pi-agent tool definition. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
- 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with foreign keys to git_repositories and users, adds indexes, and adds a workspace_id column to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy - 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with foreign keys to git_repositories and users, adds indexes, and adds a workspace_id column to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_06_13_make_clone_mode_nullable.py | Alembic database migration that makes the `clone_mode` column in `tool_instances` table nullable to support workspace-first cleanup workflow. | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy - 2026_06_13_make_clone_mode_nullable.py | Alembic database migration that makes the `clone_mode` column in `tool_instances` table nullable to allow NULL values for new rows | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
- 2026_06_14_104415_add_tool_type_home_directory.py | Alembic database migration that adds a `home_directory` column to `tool_types` table and updates template strings to use a configurable workspace path instead of hardcoded `/workspace` | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, func:downgrade() → None, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, call:op.drop_column | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py | Alembic database migration that updates the pi-agent tool definition manifest to mount repositories under the home directory instead of /workspace | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py | Alembic database migration that removes explicit repo mounts from the pi-agent tool definition manifest and synthesizes them via compile_compose instead | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:any, call:mount.get, call:mounts.append, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds five new columns (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql - 398082499c30_add_tool_config_fields.py | Alembic database migration that adds five new columns (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two parallel revision branches (removing is_builtin and adding config_profiles) into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic - 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two parallel revision branches (removing is_builtin and adding config_profiles) into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Alembic database migration that merges two divergent migration branches (profile resolver and workspaces) into a single head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic - 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Alembic database migration that merges two divergent migration branches (profile resolver and workspaces) into a single head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes or restores pi_state and pi_config mounts from the pi-agent manifest stored in tool_definition_manifests table | exp: func:_load_manifest(manifest_json), call:isinstance, call:json.loads, func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:m.get, call:mounts.append, call:json.dumps | dep: json, alembic, sqlalchemy - 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes pi_state and pi_config mounts from the pi-agent manifest in upgrade, and restores them in downgrade | exp: func:_load_manifest(manifest_json), call:isinstance, call:json.loads, func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:m.get, call:mounts.append, call:json.dumps | dep: json, alembic, sqlalchemy
- 8ed7dd80973d_create_config_folders_table.py | Alembic database migration that creates a config_folders table with user-owned configuration folders supporting JSONB file storage and project overrides | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql - 8ed7dd80973d_create_config_folders_table.py | Alembic database migration that creates a config_folders table with user-owned configuration folders supporting JSONB file storage and project overrides | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds new columns (definition_type, dockerfile_template, build_context, readiness_probe) to the tool_types table with a CHECK constraint on definition_type. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql - af8512103d67_add_tool_type_fields.py | Alembic database migration that adds new columns (definition_type, dockerfile_template, build_context, readiness_probe) to the tool_types table with a CHECK constraint on definition_type. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two prior revisions (single_interface and clone_mode) into a single migration path | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic - f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two prior revisions (single_interface and clone_mode) into a single migration path | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py | Alembic database migration that merges two divergent migration branches (home directory cleanup and pi agent mount cleanup) into a single revision history | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
## arch ## arch
Linear and branched migration history using Alembic's revision system with merge points to reconcile divergent branches; migrations use declarative SQLAlchemy operations with defensive idempotent checks, conditional existence guards, dialect-specific handling (PostgreSQL/SQLite), and in-place data migrations for template/schema evolution. Linear and branched migration pattern using Alembic's revision system with merge migrations to reconcile divergent branches; each migration is an imperative upgrade/downgrade script containing raw SQL/DDL operations, with some migrations including data seeding and dialect-specific logic (PostgreSQL/SQLite), but lacks consistent naming convention (mixed timestamp and numeric prefixes) indicating organic evolution rather than planned schema design.
## tags ## tags
column, table, call:op.drop, alembic, downgrade, upgrade, key, call:sa.text column, table, call:op.drop, downgrade, alembic, upgrade, key, call:sa.text
## symbols ## symbols
- upgrade - upgrade
- downgrade - downgrade
@@ -76,7 +72,5 @@ column, table, call:op.drop, alembic, downgrade, upgrade, key, call:sa.text
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
- change versions config - change versions config
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
- explore versions subdirectories
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
@@ -104,11 +104,11 @@ RUN git config --global init.defaultBranch main \\
&& git config --global user.name "Developer" && git config --global user.name "Developer"
# Create default tmux config # Create default tmux config
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config # Create default ranger config
RUN mkdir -p /home/user/.config/ranger \\ RUN mkdir -p /home/user/.config/ranger \\
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf && echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory # Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent RUN mkdir -p /home/user/.pi/agent
@@ -308,10 +308,10 @@ RUN git config --global init.defaultBranch main \\
&& git config --global user.email "dev@headquarter.local" \\ && git config --global user.email "dev@headquarter.local" \\
&& git config --global user.name "Developer" && git config --global user.name "Developer"
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN mkdir -p /home/user/.config/ranger \\ RUN mkdir -p /home/user/.config/ranger \\
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf && echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
RUN mkdir -p /home/user/.pi/agent RUN mkdir -p /home/user/.pi/agent
@@ -1,89 +0,0 @@
"""add_tool_type_home_directory
Revision ID: 2026_06_14_104415
Revises: f3d2dc90ba3a
Create Date: 2026-06-14 10:44:15.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_14_104415"
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_types = table(
"tool_types",
column("id", sa.UUID),
column("home_directory", sa.String),
column("compose_template", sa.Text),
column("dockerfile_template", sa.Text),
)
OLD_WORKSPACE = "/workspace"
NEW_WORKSPACE = "/home/user/{{WORKSPACE_NAME}}"
def upgrade() -> None:
# Add the new column with a default that applies to existing rows.
op.add_column(
"tool_types",
sa.Column(
"home_directory",
sa.String(255),
nullable=False,
server_default="/home/user",
),
)
# Rewrite legacy templates that mount the workspace at /workspace so they
# use the new configurable home directory and preserve the workspace name.
op.execute(
sa.update(tool_types)
.where(tool_types.c.compose_template.is_not(None))
.values(
compose_template=sa.func.replace(
tool_types.c.compose_template, OLD_WORKSPACE, NEW_WORKSPACE
)
)
)
op.execute(
sa.update(tool_types)
.where(tool_types.c.dockerfile_template.is_not(None))
.values(
dockerfile_template=sa.func.replace(
tool_types.c.dockerfile_template, OLD_WORKSPACE, NEW_WORKSPACE
)
)
)
def downgrade() -> None:
# Restore the original /workspace strings before dropping the column.
op.execute(
sa.update(tool_types)
.where(tool_types.c.compose_template.is_not(None))
.values(
compose_template=sa.func.replace(
tool_types.c.compose_template, NEW_WORKSPACE, OLD_WORKSPACE
)
)
)
op.execute(
sa.update(tool_types)
.where(tool_types.c.dockerfile_template.is_not(None))
.values(
dockerfile_template=sa.func.replace(
tool_types.c.dockerfile_template, NEW_WORKSPACE, OLD_WORKSPACE
)
)
)
op.drop_column("tool_types", "home_directory")
@@ -1,90 +0,0 @@
"""fix_pi_agent_home_directory_mount
Revision ID: 2026_06_14_182955
Revises: fc8f1a20cbf6
Create Date: 2026-06-14 18:29:55.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_14_182955"
down_revision: Union[str, Sequence[str], None] = "fc8f1a20cbf6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_definition_manifests = table(
"tool_definition_manifests",
column("id", sa.UUID),
column("name", sa.String),
column("manifest", sa.JSON),
)
def _find_pi_agent_manifest(conn: sa.Connection) -> tuple[Union[str, None], Union[dict, None]]:
result = conn.execute(
sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest)
.where(tool_definition_manifests.c.name == "pi-agent")
).fetchone()
if result is None:
return None, None
return result.id, dict(result.manifest)
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
conn.execute(
sa.update(tool_definition_manifests)
.where(tool_definition_manifests.c.id == manifest_id)
.values(manifest=manifest)
)
def upgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
# Mount the repo under the configured home directory, preserving the repo
# directory name via the WORKSPACE_NAME runtime variable.
for mount in manifest.get("mounts", []):
if mount.get("source_type") == "repo":
mount["target"] = "~/{{WORKSPACE_NAME}}"
# Keep /workspace as a compatibility symlink to the real mount path.
runtime = manifest.setdefault("runtime", {})
runtime["working_dir"] = "/workspace"
# Update the startup script to chown the real mount path.
scripts = manifest.setdefault("scripts", {})
scripts["startup"] = [
'if [ -n "$WORKSPACE_NAME" ]; then sudo chown -R user:user "$HOME/$WORKSPACE_NAME" 2>/dev/null || true; fi',
]
_update_manifest(conn, manifest_id, manifest)
def downgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
for mount in manifest.get("mounts", []):
if mount.get("source_type") == "repo":
mount["target"] = "/workspace"
runtime = manifest.setdefault("runtime", {})
runtime["working_dir"] = "/workspace"
scripts = manifest.setdefault("scripts", {})
scripts["startup"] = [
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
]
_update_manifest(conn, manifest_id, manifest)
@@ -1,86 +0,0 @@
"""remove pi agent explicit repo mount
Revision ID: 2026_06_15_090500
Revises: 2026_06_14_182955
Create Date: 2026-06-15 09:05:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_15_090500"
down_revision: Union[str, Sequence[str], None] = "2026_06_14_182955"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_definition_manifests = table(
"tool_definition_manifests",
column("id", sa.UUID),
column("name", sa.String),
column("manifest", sa.JSON),
)
def _find_pi_agent_manifest(
conn: sa.Connection,
) -> tuple[Union[str, None], Union[dict, None]]:
result = conn.execute(
sa.select(
tool_definition_manifests.c.id, tool_definition_manifests.c.manifest
).where(tool_definition_manifests.c.name == "pi-agent")
).fetchone()
if result is None:
return None, None
return result.id, dict(result.manifest)
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
conn.execute(
sa.update(tool_definition_manifests)
.where(tool_definition_manifests.c.id == manifest_id)
.values(manifest=manifest)
)
def upgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
# The repo mount is now synthesized by compile_compose based on the
# instance's repository, so the manifest no longer needs an explicit
# repo mount with a {{WORKSPACE_NAME}} placeholder.
manifest["mounts"] = [
mount
for mount in manifest.get("mounts", [])
if mount.get("source_type") != "repo"
]
_update_manifest(conn, manifest_id, manifest)
def downgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
mounts = manifest.setdefault("mounts", [])
if not any(mount.get("source_type") == "repo" for mount in mounts):
mounts.append(
{
"name": "workspace",
"target": "~/{{WORKSPACE_NAME}}",
"source_type": "repo",
"writable": True,
"owner": "user",
}
)
_update_manifest(conn, manifest_id, manifest)
@@ -1,23 +0,0 @@
"""merge home directory and pi agent mount cleanup heads
Revision ID: fc8f1a20cbf6
Revises: 2026_06_14_104415, 8c6d1dbd4798
Create Date: 2026-06-14 11:08:41.273502
"""
# revision identifiers, used by Alembic.
revision = 'fc8f1a20cbf6'
down_revision = ('2026_06_14_104415', '8c6d1dbd4798')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src dir: apps/api/src
## role ## role
Core application package for the Headquarter API, handling configuration, database connectivity, logging infrastructure, and FastAPI application lifecycle. Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware 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
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api/src
index: apps/api/src/.pi-map.index.md index: apps/api/src/.pi-map.index.md
## role ## role
Core application package for the Headquarter API, handling configuration, database connectivity, logging infrastructure, and FastAPI application lifecycle. Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
## 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-based overrides using Pydantic, including database URLs, service domains, OAuth/Authentik integration, JWT/session settings, and computed properties for environment-specific behavior. | 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, service domains, OAuth/Authentik integration, JWT/session settings, and computed properties for environment-specific behavior. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
@@ -12,7 +12,7 @@ Core application package for the Headquarter API, handling configuration, databa
- logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, 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 - logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, 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
- main.py | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | 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 | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | 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 Pydantic-based settings management, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation ID tracking, and modular FastAPI initialization with middleware pipeline. Layered architecture using Pydantic for environment-based configuration, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation IDs, and FastAPI middleware/routing pattern for a service-oriented backend.
## tags ## tags
src, database, logging, call:logger.info, api, middleware, fastapi, filter src, database, logging, call:logger.info, api, middleware, fastapi, filter
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api dir: apps/api/src/api
## role ## role
Defines the API routing package and shared validation utilities for the REST API layer. Defines shared API infrastructure including reusable Pydantic validators for consistent input validation 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
+2 -2
View File
@@ -4,12 +4,12 @@ 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
Defines the API routing package and shared validation utilities for the REST API layer. Defines shared API infrastructure including reusable Pydantic validators for consistent input validation 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 validator functions for API schema validation including mount paths, files, 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 validator functions for API schema validation including mount paths, files, 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
Modular package structure with reusable Pydantic validators for cross-cutting API schema concerns. Modular utility package with functional validation helpers using Pydantic for declarative schema enforcement.
## tags ## tags
validate, raise:value, error, call:isinstance, mount, api, init, path validate, raise:value, error, call:isinstance, mount, api, init, path
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api/workspace dir: apps/api/src/api/workspace
## role ## role
Provides FastAPI REST API endpoints for workspace management, including file operations, git version control, and tool instance orchestration within user-scoped development environments. Provides FastAPI REST endpoints for workspace management, file operations, git integration, and tool instance management within user workspaces.
## 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
+3 -3
View File
@@ -4,15 +4,15 @@ dir: apps/api/src/api/workspace
index: apps/api/src/api/workspace/.pi-map.index.md index: apps/api/src/api/workspace/.pi-map.index.md
## role ## role
Provides FastAPI REST API endpoints for workspace management, including file operations, git version control, and tool instance orchestration within user-scoped development environments. Provides FastAPI REST endpoints for workspace management, file operations, git integration, and tool instance management within user workspaces.
## files ## files
- __init__.py | Aggregates and re-exports workspace API router modules for centralized access | dep: src.api.workspace.workspace_files, src.api.workspace.workspace_git, src.api.workspace.workspace_instances, src.api.workspace.workspaces - __init__.py | Aggregates and re-exports workspace API router modules for centralized access | dep: src.api.workspace.workspace_files, src.api.workspace.workspace_git, src.api.workspace.workspace_instances, src.api.workspace.workspaces
- workspace_files.py | Provides FastAPI endpoints for listing, reading, and writing files within user workspaces with optional git commit integration. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:list_files(workspace_id: uuid.UUID, path, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.list_directory, raise:HTTPException, func:get_file_content(workspace_id: uuid.UUID, path: str, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.read_file, raise:HTTPException, func:write_file(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:FileService, call:data.get("path", "").strip, call:data.get("message", "").strip, call:service.write_file, call:GitOperations, call:git.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.shared.file_service, sqlalchemy, src.services.git.git_operations - workspace_files.py | Provides FastAPI endpoints for listing, reading, and writing files within user workspaces with optional git commit integration. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:list_files(workspace_id: uuid.UUID, path, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.list_directory, raise:HTTPException, func:get_file_content(workspace_id: uuid.UUID, path: str, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.read_file, raise:HTTPException, func:write_file(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:FileService, call:data.get("path", "").strip, call:data.get("message", "").strip, call:service.write_file, call:GitOperations, call:git.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.shared.file_service, sqlalchemy, src.services.git.git_operations
- workspace_git.py | Provides FastAPI REST endpoints for git operations (status, branches, commit, push, pull, fetch, checkout, history) scoped to user workspaces. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:git_status(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.status, raise:HTTPException, func:git_branches(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.branches, raise:HTTPException, func:git_commit(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("message", "").strip, call:GitOperations, call:git.commit, raise:HTTPException, func:git_push(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.push, raise:HTTPException, func:git_pull(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.pull, raise:HTTPException, func:git_fetch(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.fetch, raise:HTTPException, func:git_checkout(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("branch", "").strip, call:GitOperations, call:git.checkout, call:session.commit, raise:HTTPException, func:git_history(workspace_id: uuid.UUID, path, limit, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.history, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.git.git_operations, sqlalchemy - workspace_git.py | Provides FastAPI REST endpoints for git operations (status, branches, commit, push, pull, fetch, checkout, history) scoped to user workspaces. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:git_status(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.status, raise:HTTPException, func:git_branches(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.branches, raise:HTTPException, func:git_commit(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("message", "").strip, call:GitOperations, call:git.commit, raise:HTTPException, func:git_push(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.push, raise:HTTPException, func:git_pull(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.pull, raise:HTTPException, func:git_fetch(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.fetch, raise:HTTPException, func:git_checkout(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("branch", "").strip, call:GitOperations, call:git.checkout, call:session.commit, raise:HTTPException, func:git_history(workspace_id: uuid.UUID, path, limit, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.history, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.git.git_operations, sqlalchemy
- workspace_instances.py | FastAPI router providing endpoints to create and list tool instances associated with a specific workspace. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:create_workspace_instance(workspace_id: uuid.UUID, data: CreateWorkspaceInstanceRequest, user_id, session) → dict, call:_get_workspace, call:session.get, call:CreateInstanceRequest, call:str, call:create_tool_instance, call:instance.created_at.isoformat, raise:HTTPException, func:list_workspace_instances(workspace_id: uuid.UUID, user_id, session) → list[dict], call:_get_workspace, call:session.execute, call:select(ToolInstance) .where(ToolInstance.workspace_id == workspace_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.tool, src.services.tool.instance_service - workspace_instances.py | FastAPI router providing endpoints to create and list tool instances associated with a specific workspace. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:create_workspace_instance(workspace_id: uuid.UUID, data: CreateWorkspaceInstanceRequest, user_id, session) → dict, call:_get_workspace, call:session.get, call:CreateInstanceRequest, call:str, call:create_tool_instance, call:instance.created_at.isoformat, raise:HTTPException, func:list_workspace_instances(workspace_id: uuid.UUID, user_id, session) → list[dict], call:_get_workspace, call:session.execute, call:select(ToolInstance) .where(ToolInstance.workspace_id == workspace_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.tool, src.services.tool.instance_service
- workspaces.py | FastAPI router providing CRUD endpoints for managing Git repository workspaces with nested and top-level URL structures. | exp: func:list_all_workspaces(user_id, session) → list[dict], call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, GitRepository, instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) .options(selectinload(GitRepository.project)) .where(Workspace.user_id == user_id) .order_by, call:instance_count.label, call:selectinload, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:delete_workspace_top_level(workspace_id: uuid.UUID, force, user_id, session) → dict, call:session.get, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:create_workspace_top_level(data: dict, user_id, session) → dict, call:data.get("repo_id", "").strip, call:uuid.UUID, call:session.get, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:list_workspaces(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_repo, call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, instance_count.label("instance_count"), ) .where(Workspace.repo_id == repo_id) .order_by, call:instance_count.label, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:create_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:get_workspace_detail(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:session.execute, call:select(func.count(ToolInstance.id)).where, call:func.count, call:result.scalar, call:str, call:workspace.last_sync_at.isoformat, call:workspace.created_at.isoformat, call:workspace.updated_at.isoformat, func:update_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:data.get("name", "").strip, call:data.get("branch", "").strip, call:session.commit, call:session.rollback, call:logger.error, call:str, raise:HTTPException, func:delete_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, force, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:sync_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.sync, call:session.commit, call:workspace.last_sync_at.isoformat, raise:HTTPException, func:_get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) → GitRepository, call:session.execute, call:select(GitRepository) .where( GitRepository.id == repo_id, GitRepository.project_id == project_id, ) .options, call:selectinload, call:result.scalar_one_or_none, raise:HTTPException, func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, repo_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.services.shared.workspace_manager - workspaces.py | FastAPI router providing CRUD endpoints for workspace management with nested and top-level URL structures, including listing, creating, updating, deleting, and syncing workspaces tied to Git repositories. | exp: func:list_all_workspaces(user_id, session) → list[dict], call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, GitRepository, instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) .options(selectinload(GitRepository.project)) .where(Workspace.user_id == user_id) .order_by, call:instance_count.label, call:selectinload, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:delete_workspace_top_level(workspace_id: uuid.UUID, force, user_id, session) → dict, call:session.get, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:create_workspace_top_level(data: dict, user_id, session) → dict, call:data.get("repo_id", "").strip, call:uuid.UUID, call:session.get, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:list_workspaces(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_repo, call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, instance_count.label("instance_count"), ) .where(Workspace.repo_id == repo_id) .order_by, call:instance_count.label, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:create_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:get_workspace_detail(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:session.execute, call:select(func.count(ToolInstance.id)).where, call:func.count, call:result.scalar, call:str, call:workspace.last_sync_at.isoformat, call:workspace.created_at.isoformat, call:workspace.updated_at.isoformat, func:update_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:data.get("name", "").strip, call:data.get("branch", "").strip, call:session.commit, call:session.rollback, call:logger.error, call:str, raise:HTTPException, func:delete_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, force, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:sync_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.sync, call:session.commit, call:workspace.last_sync_at.isoformat, raise:HTTPException, func:_get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) → GitRepository, call:session.execute, call:select(GitRepository) .where( GitRepository.id == repo_id, GitRepository.project_id == project_id, ) .options, call:selectinload, call:result.scalar_one_or_none, raise:HTTPException, func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, repo_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.services.shared.workspace_manager
## arch ## arch
Modular router-based FastAPI architecture with domain-driven separation of concerns (files, git, instances, workspaces) using nested URL routing patterns and explicit dependency injection for cross-cutting workspace context. Modular FastAPI router pattern with domain-driven separation (files, git, instances, workspaces) using nested URL structures and git-backed workspace synchronization.
## tags ## tags
workspace, get, raise:httpexception, call:, at.isoformat, git, call:select, call:data.get workspace, get, raise:httpexception, call:, at.isoformat, git, call:select, call:data.get
## symbols ## symbols
+4 -10
View File
@@ -134,18 +134,12 @@ async def create_workspace_top_level(
workspace = await manager.create(repo, user_id, name, branch, session=session) workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace) session.add(workspace)
await session.commit() await session.commit()
except HTTPException:
raise
except ValueError as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
await session.rollback() await session.rollback()
logger.error("Failed to create workspace: %s", exc) logger.error("Failed to create workspace: %s", exc)
raise HTTPException( raise HTTPException(
status_code=500, status_code=409,
detail=f"Failed to create workspace: {exc}", detail="Workspace name already exists for this repository",
) from exc ) from exc
await session.refresh(workspace) await session.refresh(workspace)
@@ -247,8 +241,8 @@ async def create_workspace(
await session.rollback() await session.rollback()
logger.error("Failed to create workspace: %s", exc) logger.error("Failed to create workspace: %s", exc)
raise HTTPException( raise HTTPException(
status_code=500, status_code=409,
detail=f"Failed to create workspace: {exc}", detail="Workspace name already exists for this repository",
) from exc ) from exc
await session.refresh(workspace) await session.refresh(workspace)
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/models dir: apps/api/src/models
## role ## role
Provides the SQLAlchemy ORM data models and database schema definitions for the API application. Centralized database model definitions and shared infrastructure for the API's data layer.
## 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
+2 -2
View File
@@ -4,12 +4,12 @@ dir: apps/api/src/models
index: apps/api/src/models/.pi-map.index.md index: apps/api/src/models/.pi-map.index.md
## role ## role
Provides the SQLAlchemy ORM data models and database schema definitions for the API application. Centralized database model definitions and shared infrastructure for the API's data layer.
## files ## files
- __init__.py | Re-exports model classes from submodules to provide a centralized public API for the src.models package | dep: src.models.base, src.models.config.config_profile, src.models.project.git_repository, src.models.project.project, src.models.project.workspace, src.models.system.health_check, src.models.system.instance_event, src.models.system.notification, src.models.system.terminal_session, src.models.tool.tool_definition_manifest, src.models.tool.tool_instance, src.models.tool.tool_type, src.models.user.ssh_key, src.models.user.user, src.models.user.user_config - __init__.py | Re-exports model classes from submodules to provide a centralized public API for the src.models package | dep: src.models.base, src.models.config.config_profile, src.models.project.git_repository, src.models.project.project, src.models.project.workspace, src.models.system.health_check, src.models.system.instance_event, src.models.system.notification, src.models.system.terminal_session, src.models.tool.tool_definition_manifest, src.models.tool.tool_instance, src.models.tool.tool_type, src.models.user.ssh_key, src.models.user.user, src.models.user.user_config
- base.py | Defines SQLAlchemy base model and reusable mixins for UUID primary keys and automatic timestamp tracking in database models. | exp: class:Base, class:UUIDPrimaryKeyMixin, class:TimestampMixin | dep: uuid, datetime, sqlalchemy, sqlalchemy.orm - base.py | Defines SQLAlchemy base model and reusable mixins for UUID primary keys and automatic timestamp tracking in database models. | exp: class:Base, class:UUIDPrimaryKeyMixin, class:TimestampMixin | dep: uuid, datetime, sqlalchemy, sqlalchemy.orm
## arch ## arch
Layered repository pattern with declarative SQLAlchemy base, UUID/timestamp mixins for reusable model traits, and package-level facade pattern via __init__.py re-exports to centralize model access. SQLAlchemy ORM with declarative base, mixin-based composition for cross-cutting concerns (UUIDs, timestamps), and explicit package-level re-exports for clean public API surface.
## tags ## tags
models, src, base, project, system, user, mixin, tool models, src, base, project, system, user, mixin, tool
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/models/tool dir: apps/api/src/models/tool
## role ## role
Provides SQLAlchemy ORM models for managing containerized tool definitions, types, and deployed instances in the API. Database models for containerized tool lifecycle management, covering tool definitions, deployment instances, and categorization types.
## parent ## parent
index: apps/api/src/models/.pi-map.index.md index: apps/api/src/models/.pi-map.index.md
map: apps/api/src/models/.pi-map.md map: apps/api/src/models/.pi-map.md
+4 -4
View File
@@ -4,16 +4,16 @@ dir: apps/api/src/models/tool
index: apps/api/src/models/tool/.pi-map.index.md index: apps/api/src/models/tool/.pi-map.index.md
## role ## role
Provides SQLAlchemy ORM models for managing containerized tool definitions, types, and deployed instances in the API. Database models for containerized tool lifecycle management, covering tool definitions, deployment instances, and categorization types.
## files ## files
- __init__.py | Exports the public API for the tool models module by re-exporting three key classes. | dep: src.models.tool.tool_definition_manifest, src.models.tool.tool_instance, src.models.tool.tool_type - __init__.py | Exports the public API for the tool models module by re-exporting three key classes. | dep: src.models.tool.tool_definition_manifest, src.models.tool.tool_instance, src.models.tool.tool_type
- tool_definition_manifest.py | Defines a SQLAlchemy ORM model for storing tool definition manifests that compile to Dockerfiles and Compose files, supporting both base definitions and tool-specific definitions with inheritance. | exp: class:ToolDefinitionManifest | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.user - tool_definition_manifest.py | Defines a SQLAlchemy ORM model for storing tool definition manifests that compile to Dockerfiles and Compose files, supporting both base definitions and tool-specific definitions with inheritance. | exp: class:ToolDefinitionManifest | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.user
- tool_instance.py | Defines a SQLAlchemy ORM model for tool instances that represent deployed tools with container metadata, status tracking, and relationships to users, projects, workspaces, and other entities. | exp: class:ToolInstance | dep: uuid, datetime, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models, src.models.project, src.models.user, src.models (ConfigProfile, GitRepository, Project, ToolType, User, Workspace) - tool_instance.py | Defines a SQLAlchemy ORM model for tool instances that represent deployed tools with container metadata, status tracking, and relationships to users, projects, workspaces, and other entities. | exp: class:ToolInstance | dep: uuid, datetime, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models, src.models.project, src.models.user, src.models (ConfigProfile, GitRepository, Project, ToolType, User, Workspace)
- tool_type.py | Defines a SQLAlchemy ORM model for tool types that specify configuration templates and metadata for deployable tools in a containerized environment. | exp: class:ToolType | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.tool.tool_definition_manifest, src.models.user - tool_type.py | Defines a SQLAlchemy ORM model for tool types that represent configurable categories of tools with deployment templates, manifest references, and metadata. | exp: class:ToolType | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.tool.tool_definition_manifest, src.models.user
## arch ## arch
Domain-driven data models using SQLAlchemy ORM with declarative base pattern, entity relationships, and inheritance support for tool manifest definitions. SQLAlchemy ORM with declarative models using inheritance hierarchies, relationship mappings, and polymorphic manifest compilation for Docker/Compose deployment.
## tags ## tags
tool, models, src, sqlalchemy, orm, definition, base, manifest tool, models, src, sqlalchemy, orm, definition, manifest, base
## symbols ## symbols
- ToolDefinitionManifest - ToolDefinitionManifest
- ToolInstance - ToolInstance
-3
View File
@@ -39,9 +39,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
) )
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True) readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True) startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
home_directory: Mapped[str] = mapped_column(
String(255), nullable=False, default="/home/user"
)
required_variables: Mapped[list[str]] = mapped_column( required_variables: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False JSON, default=list, nullable=False
) )
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/seeds dir: apps/api/src/seeds
## role ## role
Provides database seeding utilities for initializing and synchronizing built-in data records in the API application. Provides database seeding utilities for initializing built-in tool type configurations in the API application.
## 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
+3 -3
View File
@@ -4,12 +4,12 @@ dir: apps/api/src/seeds
index: apps/api/src/seeds/.pi-map.index.md index: apps/api/src/seeds/.pi-map.index.md
## role ## role
Provides database seeding utilities for initializing and synchronizing built-in data records in the API application. Provides database seeding utilities for initializing built-in tool type configurations in the API application.
## files ## files
- __init__.py | Marks the directory as a Python package for database seeding utilities. - __init__.py | Marks the directory as a Python package for database seeding utilities.
- builtin_tool_types.py | Seeds predefined built-in tool types (code-server, jupyter-notebook, opencode) into a database with upsert logic, creating them if missing or updating existing ones to match code changes. | exp: func:_table_exists(session, table_name: str) → bool, call:session.execute, call:text, call:result.scalar, func:seed_builtin_tool_types(), call:SessionLocal, call:_table_exists, call:logger.warning, call:session.scalar, call:select(ToolType).where, call:ToolType, call:tool_data.get, call:session.add, call:logger.info, call:session.commit | dep: logging, sqlalchemy, src.database, src.models - builtin_tool_types.py | Seeds built-in tool types (code-server, jupyter-notebook, opencode) into a database with upsert logic, creating or updating Docker Compose-based development environment templates. | exp: func:_table_exists(session, table_name: str) → bool, call:session.execute, call:text, call:result.scalar, func:seed_builtin_tool_types(), call:SessionLocal, call:_table_exists, call:logger.warning, call:session.scalar, call:select(ToolType).where, call:ToolType, call:tool_data.get, call:session.add, call:logger.info, call:session.commit | dep: logging, sqlalchemy, src.database, src.models
## arch ## arch
Simple imperative seeding scripts with upsert pattern for idempotent data initialization, using direct database operations without abstraction layers. Simple procedural seeding script using SQLAlchemy upsert operations to populate reference data for containerized development environment templates.
## tags ## tags
tool, types, table, exists, builtin, call:tool, database, init tool, types, table, exists, builtin, call:tool, database, init
## symbols ## symbols
+3 -3
View File
@@ -65,7 +65,7 @@ services:
- {{REPO_PATH}}:/config/workspace - {{REPO_PATH}}:/config/workspace
ports: ports:
- "8443:8443" - "8443:8443"
restart: 'no'""", restart: unless-stopped""",
"default_port": 8443, "default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
@@ -87,7 +87,7 @@ services:
- {{REPO_PATH}}:/home/jovyan/work - {{REPO_PATH}}:/home/jovyan/work
ports: ports:
- "8888:8888" - "8888:8888"
restart: 'no'""", restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
{ {
@@ -122,7 +122,7 @@ services:
exec tail -f /dev/null" exec tail -f /dev/null"
stdin_open: true stdin_open: true
tty: true tty: true
restart: 'no'""", restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
] ]
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/services dir: apps/api/src/services
## role ## role
Service layer package for the API application, intended to contain business logic implementations. Marks the services directory as a Python package for business logic layer components.
## 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
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/src/services
index: apps/api/src/services/.pi-map.index.md index: apps/api/src/services/.pi-map.index.md
## role ## role
Service layer package for the API application, intended to contain business logic implementations. Provides a Python package namespace for organizing service-layer modules in the API application.
## files ## files
- __init__.py | Empty file with no functionality - __init__.py | Empty file with no functionality
## arch ## arch
Standard Python package structure with an empty initializer, awaiting service module implementations following a layered architecture pattern. Standard Python package structure using __init__.py for directory-based module organization, following conventional layered architecture patterns.
## tags ## tags
init, empty, functionality init, empty, functionality
## symbols ## symbols
+31 -213
View File
@@ -8,7 +8,6 @@ from typing import Any
import yaml import yaml
from src.services.config.config_profile_resolver import expand_container_path
from src.services.docker import sort_volumes_by_specificity from src.services.docker import sort_volumes_by_specificity
@@ -143,6 +142,13 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(" rm -rf /var/lib/apt/lists/*") lines.append(" rm -rf /var/lib/apt/lists/*")
lines.append("") lines.append("")
# NPM global packages
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
lines.append(f"RUN npm install -g {pkg_list}")
lines.append("")
# Pip packages # Pip packages
pip_packages = manifest.get("packages", {}).get("pip", []) pip_packages = manifest.get("packages", {}).get("pip", [])
if pip_packages: if pip_packages:
@@ -152,9 +158,6 @@ def compile_dockerfile(manifest: dict) -> str:
# User creation # User creation
user = manifest.get("user") user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
npm_prefix = ""
if user: if user:
name = user["name"] name = user["name"]
uid = user["uid"] uid = user["uid"]
@@ -165,21 +168,22 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}") lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
lines.append("") lines.append("")
# Set HOME and USER for runtime compatibility # Set HOME and USER for runtime compatibility
lines.append(f"ENV HOME={home_dir}") home = f"/home/{name}"
lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={name}") lines.append(f"ENV USER={name}")
lines.append("") lines.append("")
# Ensure home directory exists and is writable by the user. # Ensure home directory exists and is writable by the user.
# Recursively chown so any files copied from /etc/skel by useradd -m # Recursively chown so any files copied from /etc/skel by useradd -m
# (e.g. .bashrc, .config) are owned by the container user. # (e.g. .bashrc, .config) are owned by the container user.
lines.append( lines.append(
f"RUN mkdir -p {home_dir} && chown -R {name}:{name} {home_dir} && chmod 755 {home_dir}" f"RUN mkdir -p {home} && chown -R {name}:{name} {home} && chmod 755 {home}"
) )
# Pre-create common config directories so apps like ranger can write # Pre-create common config directories so apps like ranger can write
# their configs on first run without permission errors. # their configs on first run without permission errors.
common_dirs = [".config", ".local/share", ".cache"] common_dirs = [".config", ".local/share", ".cache"]
for d in common_dirs: for d in common_dirs:
lines.append( lines.append(
f"RUN mkdir -p {home_dir}/{d} && chown -R {name}:{name} {home_dir}/{d}" f"RUN mkdir -p {home}/{d} && chown -R {name}:{name} {home}/{d}"
) )
lines.append("") lines.append("")
@@ -189,20 +193,6 @@ def compile_dockerfile(manifest: dict) -> str:
) )
lines.append("") lines.append("")
# NPM global packages: install into a user-writable prefix so the
# container user can update global packages without touching
# /usr/lib/node_modules (which is owned by root).
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
npm_prefix = f"{home_dir}/.npm-global"
lines.append(
f"RUN mkdir -p {npm_prefix} && "
f"npm install -g --prefix {npm_prefix} {pkg_list}"
)
lines.append(f"ENV PATH={npm_prefix}/bin:$PATH")
lines.append("")
# Build scripts # Build scripts
build_scripts = manifest.get("scripts", {}).get("build", []) build_scripts = manifest.get("scripts", {}).get("build", [])
for script in build_scripts: for script in build_scripts:
@@ -218,38 +208,19 @@ def compile_dockerfile(manifest: dict) -> str:
# After build scripts, ensure everything in home is owned by the user # After build scripts, ensure everything in home is owned by the user
if user and build_scripts: if user and build_scripts:
lines.append(f"RUN chown -R {name}:{name} {home_dir}") lines.append(f"RUN chown -R {name}:{name} {home}")
lines.append("") lines.append("")
# Create mount target directories that do NOT depend on runtime variables. # Create mount target directories
# Targets containing {{WORKSPACE_NAME}} will be created at container
# startup by the entrypoint, once the actual workspace/repo name is known.
mounts = manifest.get("mounts", []) mounts = manifest.get("mounts", [])
static_dirs = [ if mounts:
mount["target"] for mount in mounts dirs = [mount["target"] for mount in mounts]
if "{{WORKSPACE_NAME}}" not in mount.get("target", "") dir_str = " ".join(dirs)
]
if static_dirs:
dir_str = " ".join(static_dirs)
lines.append(f"RUN mkdir -p {dir_str}") lines.append(f"RUN mkdir -p {dir_str}")
if user: if user:
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}") lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
lines.append("") lines.append("")
# Create the /workspace compatibility symlink only when the workspace name
# is known at image-build time. Otherwise the entrypoint creates it at
# runtime from the WORKSPACE_NAME environment variable.
workspace_target = f"{home_dir}/{workspace_name}"
if "{{WORKSPACE_NAME}}" not in workspace_name:
lines.append(f"RUN mkdir -p {workspace_target}")
if user:
lines.append(
f"RUN ln -sfn {workspace_target} /workspace && chown -R {user['name']}:{user['name']} {home_dir}"
)
else:
lines.append(f"RUN ln -sfn {workspace_target} /workspace")
lines.append("")
# Entrypoint for startup scripts # Entrypoint for startup scripts
startup_scripts = manifest.get("scripts", {}).get("startup", []) startup_scripts = manifest.get("scripts", {}).get("startup", [])
if startup_scripts: if startup_scripts:
@@ -259,22 +230,14 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint") lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
lines.append("") lines.append("")
# Do not switch to the runtime user in the Dockerfile. The entrypoint # Switch to runtime user
# starts as root so it can create the /workspace compatibility symlink if user:
# (which lives under /) and fix mount ownership, then it drops privileges lines.append(f"USER {user['name']}")
# to the container user before exec-ing the real command. lines.append(f"WORKDIR /home/{user['name']}")
lines.append("")
# Set WORKDIR to the configured home directory unless runtime.working_dir
# explicitly overrides it.
runtime = manifest.get("runtime", {})
working_dir = runtime.get("working_dir")
if working_dir:
lines.append(f"WORKDIR {expand_container_path(working_dir, home_dir)}")
else:
lines.append(f"WORKDIR {home_dir}")
lines.append("")
# Entrypoint and CMD # Entrypoint and CMD
runtime = manifest.get("runtime", {})
if startup_scripts: if startup_scripts:
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]') lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
@@ -288,11 +251,6 @@ def compile_dockerfile(manifest: dict) -> str:
def compile_entrypoint(manifest: dict) -> str: def compile_entrypoint(manifest: dict) -> str:
"""Generate the startup entrypoint script from startup scripts. """Generate the startup entrypoint script from startup scripts.
Injects a permission-fixer preamble that runs as root (or via sudo) before
any user-defined startup script. It chowns the home directory and a safe
subset of mount parents to the container user, creates the /workspace
compatibility symlink, and avoids recursive chown of large repo subtrees.
Args: Args:
manifest: Fully resolved manifest JSON. manifest: Fully resolved manifest JSON.
@@ -301,108 +259,12 @@ def compile_entrypoint(manifest: dict) -> str:
""" """
lines = ["#!/bin/bash", "set -e", ""] lines = ["#!/bin/bash", "set -e", ""]
user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
# Permission fixer preamble: run as root when possible, else fall back to
# passwordless sudo configured in the Dockerfile.
lines.append("# Permission fixer preamble")
lines.append("CONTAINER_USER=''")
lines.append('if [ "$(id -u)" = '"'"'0'"'"' ]; then')
if user:
lines.append(f" CONTAINER_USER='{user['name']}'")
lines.append("else")
lines.append(" # Try passwordless sudo; ignore failure so the container still starts")
lines.append(" if sudo -n true 2>/dev/null; then")
lines.append(" SUDO='sudo'")
lines.append(" else")
lines.append(" SUDO=''")
lines.append(" fi")
lines.append("fi")
lines.append("")
if user:
name = user["name"]
uid = user["uid"]
gid = user["gid"]
lines.append(f"USER_NAME='{name}'")
lines.append(f"USER_UID='{uid}'")
lines.append(f"USER_GID='{gid}'")
lines.append(f"HOME_DIR='{home_dir}'")
lines.append('WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"')
lines.append('WORKSPACE_TARGET="${HOME_DIR}/${WORKSPACE_NAME}"')
lines.append("")
lines.append("fix_owner() {")
lines.append(" local path=\"$1\"")
lines.append(' [ -e "$path" ] || return 0')
lines.append(' if [ -n "$SUDO" ]; then')
lines.append(' sudo chown "$USER_UID:$USER_GID" "$path" 2>/dev/null || true')
lines.append(' elif [ "$(id -u)" = "0" ]; then')
lines.append(' chown "$USER_UID:$USER_GID" "$path" 2>/dev/null || true')
lines.append(' fi')
lines.append("}")
lines.append("")
lines.append("# Ensure home directory exists and is owned by the container user")
lines.append('mkdir -p "$HOME_DIR"')
lines.append('fix_owner "$HOME_DIR"')
lines.append("")
lines.append("# Ensure workspace target exists and is owned by the container user")
lines.append('mkdir -p "$WORKSPACE_TARGET"')
lines.append('fix_owner "$WORKSPACE_TARGET"')
lines.append("")
lines.append("# Remove stale placeholder directory baked into older images")
lines.append('if [ -d "${HOME_DIR}/{{WORKSPACE_NAME}}" ]; then')
lines.append(' rm -rf "${HOME_DIR}/{{WORKSPACE_NAME}}"')
lines.append('fi')
lines.append("")
lines.append("# Create /workspace compatibility symlink")
lines.append("# / is owned by root, so we need root or passwordless sudo.")
lines.append('if [ "$(id -u)" = "0" ]; then')
lines.append(' ln -sfn "$WORKSPACE_TARGET" /workspace')
lines.append('elif [ -n "$SUDO" ]; then')
lines.append(' sudo ln -sfn "$WORKSPACE_TARGET" /workspace')
lines.append('else')
lines.append(' ln -sfn "$WORKSPACE_TARGET" /workspace 2>/dev/null || true')
lines.append('fi')
lines.append("")
lines.append("# Fix ownership of declared mount targets (top-level only)")
for mount in manifest.get("mounts", []):
target = mount.get("target")
if not target:
continue
# Expand any ~/$HOME placeholders and the runtime workspace name
# in the mount target so ownership is fixed at container startup.
expanded = (
target.replace("~", home_dir)
.replace("$HOME", home_dir)
.replace("{{WORKSPACE_NAME}}", "${WORKSPACE_NAME}")
)
if expanded.startswith(home_dir) and not mount.get("readonly", False):
lines.append(f'fix_owner "{expanded}"')
lines.append("")
startup_scripts = manifest.get("scripts", {}).get("startup", []) startup_scripts = manifest.get("scripts", {}).get("startup", [])
for script in startup_scripts: for script in startup_scripts:
lines.append(script) lines.append(script)
lines.append("") lines.append("")
# Drop from root to the container user before running the real command. lines.append('exec "$@"')
# The Dockerfile no longer sets USER, so the entrypoint has root for the
# setup above. `runuser` (root-only, no PAM) preserves the environment,
# stdin, and TTY so interactive tools like bash keep running.
if user:
name = user["name"]
lines.append("# Drop privileges to the container user")
# When the container command is a shell, force an interactive login
# shell. Detached containers may not have stdin connected, and a plain
# /bin/bash invocation exits immediately with code 0. -il keeps it
# alive so the container stays running for docker exec/web terminals.
lines.append('if [ "$1" = "/bin/bash" ] || [ "$1" = "bash" ]; then')
lines.append(f' exec runuser -u {name} -- /bin/bash -il')
lines.append('fi')
lines.append(f'exec runuser -u {name} -- "$@"')
else:
lines.append('exec "$@"')
return "\n".join(lines) return "\n".join(lines)
@@ -419,18 +281,11 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
runtime = manifest.get("runtime", {}) runtime = manifest.get("runtime", {})
user = manifest.get("user") user = manifest.get("user")
interface_type = manifest["interface_type"] interface_type = manifest["interface_type"]
home_dir = get_manifest_home_dir(manifest)
# Determine the workspace/repo name from variables when available.
workspace_name = variables.get(
"WORKSPACE_NAME",
variables.get("REPO_NAME", "workspace"),
)
service: dict[str, Any] = { service: dict[str, Any] = {
"image": variables["IMAGE_TAG"], "image": variables["IMAGE_TAG"],
"container_name": variables["INSTANCE_NAME"], "container_name": variables["INSTANCE_NAME"],
"restart": "no", "restart": "unless-stopped",
} }
# Terminal-specific fields # Terminal-specific fields
@@ -439,16 +294,11 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if runtime.get("tty", False): if runtime.get("tty", False):
service["tty"] = True service["tty"] = True
if runtime.get("working_dir"): if runtime.get("working_dir"):
service["working_dir"] = expand_container_path( service["working_dir"] = runtime["working_dir"]
runtime["working_dir"], home_dir
)
# The entrypoint starts as root (Dockerfile does not set USER) so it can # User override
# create the /workspace compatibility symlink and fix mount ownership. It
# drops privileges to the container user internally before exec-ing the
# real command, so do not set compose-level user override here.
if user: if user:
service["user"] = "0:0" service["user"] = f"{user['uid']}:{user['gid']}"
# Ports for web tools # Ports for web tools
default_port = manifest.get("default_port") default_port = manifest.get("default_port")
@@ -460,12 +310,6 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if env: if env:
service["environment"] = dict(env) service["environment"] = dict(env)
# Expose the workspace/repo name so the entrypoint can finalize the
# /workspace compatibility symlink at container startup.
if "environment" not in service:
service["environment"] = {}
service["environment"]["WORKSPACE_NAME"] = workspace_name
# Merge extra env from config # Merge extra env from config
extra_env = variables.get("EXTRA_ENV", {}) extra_env = variables.get("EXTRA_ENV", {})
if extra_env: if extra_env:
@@ -475,25 +319,14 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
# Volumes from mount schema # Volumes from mount schema
volumes = [] volumes = []
has_explicit_repo_mount = False
for mount in manifest.get("mounts", []): for mount in manifest.get("mounts", []):
source = resolve_mount_source(mount, variables) source = resolve_mount_source(mount, variables)
if not source: if not source:
continue continue
if mount.get("source_type") == "repo": target = mount["target"]
has_explicit_repo_mount = True
target = expand_container_path(mount["target"], home_dir)
target = target.replace("{{WORKSPACE_NAME}}", workspace_name)
readonly = ":ro" if mount.get("readonly", False) else "" readonly = ":ro" if mount.get("readonly", False) else ""
volumes.append(f"{source}:{target}{readonly}") volumes.append(f"{source}:{target}{readonly}")
# Synthesize a default repo/workspace mount when the manifest does not
# declare an explicit repo mount. This preserves the repo root directory
# name under the configured home directory.
if not has_explicit_repo_mount and variables.get("REPO_PATH"):
target = f"{home_dir}/{workspace_name}"
volumes.append(f"{variables['REPO_PATH']}:{target}")
# Append extra volumes from tool config / config profile # Append extra volumes from tool config / config profile
for vol in variables.get("EXTRA_VOLUMES", []): for vol in variables.get("EXTRA_VOLUMES", []):
vol_str = f"{vol['source']}:{vol['target']}" vol_str = f"{vol['source']}:{vol['target']}"
@@ -552,12 +385,7 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
def get_manifest_home_dir(manifest: dict) -> str: def get_manifest_home_dir(manifest: dict) -> str:
"""Get the home directory for a container based on manifest config. """Get the home directory for a container based on manifest user config.
Precedence:
1. manifest["home_directory"] if present and non-empty.
2. /home/{user.name} if manifest.user.name is present.
3. /root otherwise.
Args: Args:
manifest: Fully resolved manifest JSON. manifest: Fully resolved manifest JSON.
@@ -565,10 +393,6 @@ def get_manifest_home_dir(manifest: dict) -> str:
Returns: Returns:
Home directory path (e.g., /home/user or /root). Home directory path (e.g., /home/user or /root).
""" """
home_directory = manifest.get("home_directory")
if home_directory and isinstance(home_directory, str) and home_directory.strip():
return home_directory.strip()
user = manifest.get("user") user = manifest.get("user")
if user and user.get("name"): if user and user.get("name"):
return f"/home/{user['name']}" return f"/home/{user['name']}"
@@ -578,10 +402,6 @@ def get_manifest_home_dir(manifest: dict) -> str:
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.
The hash includes the manifest JSON plus a compiler version token so
that changes to the Dockerfile/entrypoint generation logic invalidate
previously built images.
Args: Args:
tool_name: Human-readable tool name. tool_name: Human-readable tool name.
manifest: Fully resolved manifest JSON. manifest: Fully resolved manifest JSON.
@@ -589,11 +409,9 @@ def compute_image_tag(tool_name: str, manifest: dict) -> str:
Returns: Returns:
Docker image tag string. Docker image tag string.
""" """
compiler_version = "v4" # bump when compile_dockerfile/entrypoint/compose change # Canonicalize: sort keys, stable JSON
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":")) canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
hash_suffix = hashlib.sha256( hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
f"{compiler_version}:{canonical}".encode()
).hexdigest()[:8]
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-") safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
return f"headquarter/{safe_name}-{hash_suffix}:latest" return f"headquarter/{safe_name}-{hash_suffix}:latest"
@@ -172,47 +172,6 @@ def _merge_mounts(
return result return result
def _find_mount_conflicts(
profile: ConfigProfile,
resolved: ResolvedProfile,
) -> list[dict[str, Any]]:
"""Find mounts on the profile that override mounts from included profiles.
Returns a list of conflict descriptors with the target path, the included
profile that originally provided the mount, and the current profile name.
"""
conflicts = []
own_targets = {m["target"] for m in (profile.mounts or [])}
own_files = {f for m in (profile.mounts or []) for f in m.get("files", {}).keys()}
for mount in resolved.mounts.values():
if mount.target in own_targets:
# The profile itself has a mount at the same target as an included one.
conflicts.append(
{
"type": "mount_target",
"target": mount.target,
"overridden_by": profile.name,
"source": resolved.profile_name,
}
)
continue
for rel_path in mount.files:
if rel_path in own_files:
conflicts.append(
{
"type": "mount_file",
"target": mount.target,
"file": rel_path,
"overridden_by": profile.name,
"source": resolved.profile_name,
}
)
return conflicts
def _merge_git_mounts( def _merge_git_mounts(
base: list[dict[str, Any]], base: list[dict[str, Any]],
overlay: list[dict[str, Any]], overlay: list[dict[str, Any]],
@@ -2,7 +2,7 @@
dir: apps/api/src/services/docker dir: apps/api/src/services/docker
## role ## role
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling. Provides Docker infrastructure services for container lifecycle management, compose orchestration, configuration deployment, and secure tunneling to expose internal services.
## 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 -4
View File
@@ -4,15 +4,15 @@ dir: apps/api/src/services/docker
index: apps/api/src/services/docker/.pi-map.index.md index: apps/api/src/services/docker/.pi-map.index.md
## role ## role
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling. Provides Docker infrastructure services for container lifecycle management, compose orchestration, configuration deployment, and secure tunneling to expose internal services.
## files ## files
- __init__.py | Package initialization file that exposes Docker-related service functions for container operations, compose management, configuration staging, and tunnel management. | dep: src.services.docker.compose, src.services.docker.config_staging, src.services.docker.container, src.services.docker.tunnel - __init__.py | Package initialization file that exposes Docker-related service functions for container operations, compose management, configuration staging, and tunnel management. | dep: src.services.docker.compose, src.services.docker.config_staging, src.services.docker.container, src.services.docker.tunnel
- compose.py | Generates, renders, and executes Docker Compose files with volume sorting and template variable substitution. | exp: func:sort_volumes_by_specificity(volumes: list[str]) → list[str], call:vol.split, call:len, call:parts[1].rstrip, call:target.count, call:targets.append, call:Counter(targets).items, call:logger.warning, call:sorted, func:_target_depth(vol: str) → int, call:vol.split, call:len, call:parts[1].rstrip, call:target.count, func:render_compose_template(template: str, variables: dict[str, Any]) → str, call:variables.items, call:result.replace, call:str, call:aliases.items, call:variables.get, func:write_compose_file(instance_dir: str, content: str) → str, call:Path, call:compose_path.write_text, call:str, func:execute_compose_command(compose_path: str, action: str, timeout, env_file) → tuple[int, str, str], call:Path, call:cmd.extend, call:cmd.append, call:subprocess.run, call:str, raise:ValueError | dep: logging, subprocess, collections, pathlib, typing, collections.Counter, pathlib.Path, typing.Any - compose.py | Generates, renders, and executes Docker Compose commands for container orchestration with volume sorting and template substitution. | exp: func:sort_volumes_by_specificity(volumes: list[str]) → list[str], call:vol.split, call:len, call:parts[1].rstrip, call:target.count, call:targets.append, call:Counter(targets).items, call:logger.warning, call:sorted, func:_target_depth(vol: str) → int, call:vol.split, call:len, call:parts[1].rstrip, call:target.count, func:render_compose_template(template: str, variables: dict[str, Any]) → str, call:variables.items, call:result.replace, call:str, func:write_compose_file(instance_dir: str, content: str) → str, call:Path, call:compose_path.write_text, call:str, func:execute_compose_command(compose_path: str, action: str, timeout, env_file) → tuple[int, str, str], call:Path, call:cmd.extend, call:cmd.append, call:subprocess.run, call:str, raise:ValueError | dep: logging, subprocess, collections, pathlib, typing, collections.Counter, pathlib.Path, typing.Any
- config_staging.py | Stages configuration files into instance directories with security checks for path traversal. | exp: func:ensure_instance_directory(instance_id: str, base_path) → str, call:Settings, call:Path, call:instance_dir.mkdir, call:str, call:instance_dir.absolute, func:write_env_file(instance_dir: str, env_vars: dict[str, str]) → str, call:Path, call:env_vars.items, call:env_path.write_text, call:"\n".join, call:str, func:write_config_files(instance_dir: str, files: dict[str, str]) → None, call:Path, call:files.items, call:full_path.resolve().relative_to, call:instance_path.resolve, call:full_path.parent.mkdir, call:full_path.write_text, raise:ValueError | dep: logging, pathlib, src.config, src.config.Settings - config_staging.py | Stages configuration files into instance directories with security checks for path traversal. | exp: func:ensure_instance_directory(instance_id: str, base_path) → str, call:Settings, call:Path, call:instance_dir.mkdir, call:str, call:instance_dir.absolute, func:write_env_file(instance_dir: str, env_vars: dict[str, str]) → str, call:Path, call:env_vars.items, call:env_path.write_text, call:"\n".join, call:str, func:write_config_files(instance_dir: str, files: dict[str, str]) → None, call:Path, call:files.items, call:full_path.resolve().relative_to, call:instance_path.resolve, call:full_path.parent.mkdir, call:full_path.write_text, raise:ValueError | dep: logging, pathlib, src.config, src.config.Settings
- container.py | Provides utility functions for querying Docker container runtime state, managing container network connections, and finding free TCP ports via subprocess calls to the Docker CLI. | exp: func:get_container_id(instance_name: str) → str | None, call:instance_name.lower, call:subprocess.run, call:result.stdout.strip, call:ps_result.stdout.strip().splitlines, call:line.split, call:len, call:name.lower, func:get_container_name(instance_name: str) → str | None, call:subprocess.run, call:instance_name.lower, call:result.stdout.strip().lstrip, func:get_backend_network_name() → str, call:subprocess.run, call:result.stdout.strip().split, call:net.lower, func:connect_container_to_network(container_name: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_ip_on_network(container_id: str, network_name) → str | None, call:get_backend_network_name, call:subprocess.run, call:result.stdout.strip, func:is_container_on_network(container_id: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_status(container_id: str) → dict[str, Any], call:subprocess.run, call:result.stdout.strip().split, call:int, call:len, call:parts[1].isdigit, func:wait_for_container_running(container_id: str, timeout, interval) → dict[str, Any], call:time.time, call:get_container_status, call:time.sleep, func:get_container_logs(container_id: str, tail) → str, call:subprocess.run, call:str, func:find_free_port(start, end) → int, call:range, call:socket.socket, call:s.connect_ex, raise:RuntimeError | dep: logging, subprocess, time, typing, socket - container.py | Provides Docker container runtime queries and network management utilities via subprocess calls to the Docker CLI. | exp: func:get_container_id(instance_name: str) → str | None, call:instance_name.lower, call:subprocess.run, call:result.stdout.strip, call:ps_result.stdout.strip().splitlines, call:line.split, call:len, call:name.lower, func:get_container_name(instance_name: str) → str | None, call:subprocess.run, call:instance_name.lower, call:result.stdout.strip().lstrip, func:get_backend_network_name() → str, call:subprocess.run, call:result.stdout.strip().split, call:net.lower, func:connect_container_to_network(container_name: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_ip_on_network(container_id: str, network_name) → str | None, call:get_backend_network_name, call:subprocess.run, call:result.stdout.strip, func:is_container_on_network(container_id: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_status(container_id: str) → dict[str, Any], call:subprocess.run, call:result.stdout.strip().split, call:int, call:len, call:parts[1].isdigit, func:wait_for_container_running(container_id: str, timeout, interval) → dict[str, Any], call:time.time, call:get_container_status, call:time.sleep, func:get_container_logs(container_id: str, tail) → str, call:subprocess.run, call:str, func:find_free_port(start, end) → int, call:range, call:socket.socket, call:s.connect_ex, raise:RuntimeError | dep: logging, subprocess, time, typing, socket
- tunnel.py | Manages Cloudflare tunnels by orchestrating cloudflared Docker containers to expose internal services via temporary public URLs. | exp: func:_tunnel_container_name(instance_name: str) → str, call:instance_name.lower, func:_ensure_image() → None, call:subprocess.run, call:result.stdout.strip, call:logger.info, call:logger.warning, func:_cleanup_stale_tunnel(tunnel_name: str) → None, call:subprocess.run, func:_get_tunnel_logs(tunnel_name: str) → tuple[str, str], call:subprocess.run, func:_get_tunnel_exit_code(tunnel_name: str) → int | None, call:subprocess.run, call:int, call:result.stdout.strip, func:start_tunnel(instance_name: str, container_port: int, timeout, target_url) → dict[str, str], call:_ensure_image, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:instance_name.lower, call:get_backend_network_name, call:logger.debug, call:" ".join, call:subprocess.run, call:proc.stdout.strip, call:re.compile, call:__import__("time").time, call:_get_tunnel_logs, call:url_pattern.search, call:match.group, call:_get_tunnel_exit_code, call:__import__("time").sleep, call:logger.info, raise:RuntimeError, func:stop_tunnel(instance_name: str) → None, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:logger.debug, func:recreate_tunnel(instance_name: str, container_port: int, target_url) → dict[str, str], call:stop_tunnel, call:start_tunnel, func:check_tunnel_health(url: str, timeout) → dict[str, Any], call:subprocess.run, call:int, call:result.stdout.strip, call:str(exc).lower, call:any | dep: logging, re, subprocess, typing, src.services.docker.container - tunnel.py | Manages Cloudflare tunnels by orchestrating cloudflared Docker containers to expose internal services via temporary public URLs. | exp: func:_tunnel_container_name(instance_name: str) → str, call:instance_name.lower, func:_ensure_image() → None, call:subprocess.run, call:result.stdout.strip, call:logger.info, call:logger.warning, func:_cleanup_stale_tunnel(tunnel_name: str) → None, call:subprocess.run, func:_get_tunnel_logs(tunnel_name: str) → tuple[str, str], call:subprocess.run, func:_get_tunnel_exit_code(tunnel_name: str) → int | None, call:subprocess.run, call:int, call:result.stdout.strip, func:start_tunnel(instance_name: str, container_port: int, timeout, target_url) → dict[str, str], call:_ensure_image, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:instance_name.lower, call:get_backend_network_name, call:logger.debug, call:" ".join, call:subprocess.run, call:proc.stdout.strip, call:re.compile, call:__import__("time").time, call:_get_tunnel_logs, call:url_pattern.search, call:match.group, call:_get_tunnel_exit_code, call:__import__("time").sleep, call:logger.info, raise:RuntimeError, func:stop_tunnel(instance_name: str) → None, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:logger.debug, func:recreate_tunnel(instance_name: str, container_port: int, target_url) → dict[str, str], call:stop_tunnel, call:start_tunnel, func:check_tunnel_health(url: str, timeout) → dict[str, Any], call:subprocess.run, call:int, call:result.stdout.strip, call:str(exc).lower, call:any | dep: logging, re, subprocess, typing, src.services.docker.container
## arch ## arch
Subprocess-based CLI wrapper architecture with template-driven file generation, security-validated file staging, and container-orchestrated tunnel proxying. Subprocess-based CLI wrapper architecture around Docker/cloudflared tools with template rendering, path-traversal-safe file staging, and functional decomposition into single-responsibility modules.
## tags ## tags
tunnel, container, call:subprocess.run, get, name, call:, network, call:result.stdout.strip tunnel, container, call:subprocess.run, get, name, call:, network, call:result.stdout.strip
## symbols ## symbols
-13
View File
@@ -61,19 +61,6 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
for key, value in variables.items(): for key, value in variables.items():
placeholder = f"{{{{{key}}}}}" placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value)) result = result.replace(placeholder, str(value))
# Convenience aliases so legacy and migrated templates can use lowercase
# placeholders without changing every stored template.
aliases = {
"{{workspace_name}}": "WORKSPACE_NAME",
"{{home_directory}}": "HOME_DIRECTORY",
}
for alias_placeholder, key in aliases.items():
if alias_placeholder in result:
result = result.replace(
alias_placeholder, str(variables.get(key, "workspace"))
)
return result return result
+4 -10
View File
@@ -283,7 +283,7 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
tail: Number of lines to return tail: Number of lines to return
Returns: Returns:
Container logs (stdout + stderr) Container logs
""" """
result = subprocess.run( result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id], ["docker", "logs", "--tail", str(tail), container_id],
@@ -291,15 +291,9 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
text=True, text=True,
) )
if result.returncode != 0: if result.returncode == 0:
return f"Failed to get logs: {result.stderr}" return result.stdout
return f"Failed to get logs: {result.stderr}"
logs = result.stdout
if result.stderr:
if logs:
logs += "\n"
logs += f"STDERR:\n{result.stderr}"
return logs
def find_free_port(start: int = 10000, end: int = 20000) -> int: def find_free_port(start: int = 10000, end: int = 20000) -> int:
@@ -2,7 +2,7 @@
dir: apps/api/src/services/instance dir: apps/api/src/services/instance
## role ## role
Coordinates tool instance lifecycle events, health monitoring, and notifications across the API service. Provides infrastructure for managing tool instance lifecycle events, health monitoring, and asynchronous communication within the API service.
## 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
+3 -3
View File
@@ -4,14 +4,14 @@ dir: apps/api/src/services/instance
index: apps/api/src/services/instance/.pi-map.index.md index: apps/api/src/services/instance/.pi-map.index.md
## role ## role
Coordinates tool instance lifecycle events, health monitoring, and notifications across the API service. Provides infrastructure for managing tool instance lifecycle events, health monitoring, and asynchronous communication within the API service.
## files ## files
- __init__.py | Exports public API for instance lifecycle services module | dep: src.services.instance.event_bus, src.services.instance.health_monitor, src.services.instance.lifecycle_hooks - __init__.py | Exports public API for instance lifecycle services module | dep: src.services.instance.event_bus, src.services.instance.health_monitor, src.services.instance.lifecycle_hooks
- event_bus.py | Implements a singleton in-memory typed event bus with publish/subscribe pattern for instance lifecycle and health events, supporting both sync and async callbacks with exception isolation. | exp: class:InstanceEventBus, method:__init__(self) → None, method:__new__(cls) → "InstanceEventBus", call:super().__new__, method:_reset_for_testing(self) → None, call:self._subscribers.clear, method:subscribe(self, event_type: str, callback: EventCallback) → Callable[[], None], call:str, call:uuid.uuid4, call:self._subscribers[event_type].append, call:self.unsubscribe, method:unsubscribe(self, event_type: str, callback_id: str) → None, method:unsubscribe_all(self, event_type: str) → None, call:self._subscribers.pop, method:publish(self, event_type: str, payload: InstanceEventPayload) → None, call:callbacks.extend, call:self._subscribers.get, call:inspect.iscoroutinefunction, call:callback, call:payload.get, call:logger.exception | dep: asyncio, inspect, logging, uuid, collections.abc, typing - event_bus.py | Implements a singleton in-memory typed event bus with publish/subscribe pattern for instance lifecycle and health events, supporting both sync and async callbacks with exception isolation. | exp: class:InstanceEventBus, method:__init__(self) → None, method:__new__(cls) → "InstanceEventBus", call:super().__new__, method:_reset_for_testing(self) → None, call:self._subscribers.clear, method:subscribe(self, event_type: str, callback: EventCallback) → Callable[[], None], call:str, call:uuid.uuid4, call:self._subscribers[event_type].append, call:self.unsubscribe, method:unsubscribe(self, event_type: str, callback_id: str) → None, method:unsubscribe_all(self, event_type: str) → None, call:self._subscribers.pop, method:publish(self, event_type: str, payload: InstanceEventPayload) → None, call:callbacks.extend, call:self._subscribers.get, call:inspect.iscoroutinefunction, call:callback, call:payload.get, call:logger.exception | dep: asyncio, inspect, logging, uuid, collections.abc, typing
- health_monitor.py | Background health monitor that polls Docker container and tunnel health for tool instances, publishes state change events, and creates notifications for errors/unhealthy states. | exp: class:HealthSnapshot, class:HealthMonitor, method:__init__(self, event_bus: InstanceEventBus) → None, method:start(self) → None, call:self._task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._poll_loop, method:stop(self) → None, call:self._task.done, call:self._task.cancel, call:self._last_known_state.clear, method:_poll_loop(self) → None, call:asyncio.sleep, call:self._run_check_cycle, call:logger.exception, method:_run_check_cycle(self) → None, call:SessionLocal, call:session.execute, call:select(ToolInstance).where, call:ToolInstance.status.in_, call:result.scalars().all, call:self._check_instance, method:_check_instance(self, session: AsyncSession, instance: ToolInstance) → None, call:logger.debug, call:get_container_status, call:logger.exception, call:str, call:get_correlation_id, call:check_tunnel_health, call:tunnel_result.get, call:HealthSnapshot, call:self._last_known_state.get, call:self._derive_status, call:self._snapshots_equal, call:self._handle_state_change, method:_derive_status(self, snapshot: HealthSnapshot, previous: HealthSnapshot | None, current_status: str | None) → str, method:_snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) → bool, method:_handle_state_change(self, session: AsyncSession, instance: ToolInstance, previous: HealthSnapshot | None, snapshot: HealthSnapshot, new_status: str) → None, call:HealthCheck, call:session.add, call:session.commit, call:get_correlation_id, call:str, call:datetime.now(timezone.utc).isoformat, call:self._event_bus.publish, call:notification_service.create_notification, call:logger.exception | dep: asyncio, logging, uuid, dataclasses, datetime, sqlalchemy, sqlalchemy.ext.asyncio, src.database, src.models, src.services.shared.correlation, src.services.docker, src.services.shared.tunnel, src.services.instance.event_bus, src.services.shared.notification_service - health_monitor.py | Background health monitor that periodically polls Docker container and tunnel health for tool instances, publishing state change events and notifications. | exp: class:HealthSnapshot, class:HealthMonitor, method:__init__(self, event_bus: InstanceEventBus) → None, method:start(self) → None, call:self._task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._poll_loop, method:stop(self) → None, call:self._task.done, call:self._task.cancel, call:self._last_known_state.clear, method:_poll_loop(self) → None, call:asyncio.sleep, call:self._run_check_cycle, call:logger.exception, method:_run_check_cycle(self) → None, call:SessionLocal, call:session.execute, call:select(ToolInstance).where, call:ToolInstance.status.in_, call:result.scalars().all, call:self._check_instance, method:_check_instance(self, session: AsyncSession, instance: ToolInstance) → None, call:logger.debug, call:get_container_status, call:logger.exception, call:str, call:get_correlation_id, call:check_tunnel_health, call:tunnel_result.get, call:HealthSnapshot, call:self._last_known_state.get, call:self._derive_status, call:self._snapshots_equal, call:self._handle_state_change, method:_derive_status(self, snapshot: HealthSnapshot, previous: HealthSnapshot | None, current_status: str | None) → str, method:_snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) → bool, method:_handle_state_change(self, session: AsyncSession, instance: ToolInstance, previous: HealthSnapshot | None, snapshot: HealthSnapshot, new_status: str) → None, call:HealthCheck, call:session.add, call:session.commit, call:get_correlation_id, call:str, call:datetime.now(timezone.utc).isoformat, call:self._event_bus.publish, call:notification_service.create_notification, call:logger.exception | dep: asyncio, logging, uuid, dataclasses, datetime, sqlalchemy, sqlalchemy.ext.asyncio, src.database, src.models, src.services.shared.correlation, src.services.docker, src.services.shared.tunnel, src.services.instance.event_bus, src.services.shared.notification_service
- lifecycle_hooks.py | Provides helpers to publish tool instance lifecycle events, persist audit records, and conditionally send user notifications. | exp: func:_derive_title(event_type: str) → str, call:mapping.get, call:event_type.replace("instance.", "").replace("_", " ").title, func:_should_notify(event_type: str, status: str | None) → bool, func:_build_payload(event_type: str, instance: ToolInstance, status, message, metadata) → InstanceEventPayload, call:str, call:datetime.now(timezone.utc).isoformat, call:get_correlation_id, func:_write_audit_row(session: AsyncSession, instance: ToolInstance, event_type: str, created_by, status, message, metadata) → InstanceEvent, call:InstanceEvent, call:event_type.replace, call:session.add, call:session.commit, func:publish_lifecycle_event(event_bus: InstanceEventBus, session: AsyncSession, instance: ToolInstance, event_type: str, created_by, status, message, metadata) → None, call:_build_payload, call:_write_audit_row, call:event_bus.publish, call:_should_notify, call:_derive_title, call:notification_service.create_notification, call:logger.exception, call:payload.get | dep: logging, uuid, datetime, sqlalchemy.ext.asyncio, src.models, src.services.shared.correlation, src.services.instance.event_bus, src.services.shared.notification_service - lifecycle_hooks.py | Provides helpers to publish tool instance lifecycle events, persist audit records, and conditionally send user notifications. | exp: func:_derive_title(event_type: str) → str, call:mapping.get, call:event_type.replace("instance.", "").replace("_", " ").title, func:_should_notify(event_type: str, status: str | None) → bool, func:_build_payload(event_type: str, instance: ToolInstance, status, message, metadata) → InstanceEventPayload, call:str, call:datetime.now(timezone.utc).isoformat, call:get_correlation_id, func:_write_audit_row(session: AsyncSession, instance: ToolInstance, event_type: str, created_by, status, message, metadata) → InstanceEvent, call:InstanceEvent, call:event_type.replace, call:session.add, call:session.commit, func:publish_lifecycle_event(event_bus: InstanceEventBus, session: AsyncSession, instance: ToolInstance, event_type: str, created_by, status, message, metadata) → None, call:_build_payload, call:_write_audit_row, call:event_bus.publish, call:_should_notify, call:_derive_title, call:notification_service.create_notification, call:logger.exception, call:payload.get | dep: logging, uuid, datetime, sqlalchemy.ext.asyncio, src.models, src.services.shared.correlation, src.services.instance.event_bus, src.services.shared.notification_service
## arch ## arch
Observer pattern via typed singleton event bus with async/sync subscribers, background polling loops, and side-effect hooks for persistence and notifications. Event-driven architecture using a singleton in-memory pub/sub event bus with typed messages, background polling workers, and lifecycle hooks that bridge domain events to persistence and notifications with exception isolation between sync/async handlers.
## tags ## tags
call:self., instance, src, services, event, health, call:logger.exception, check call:self., instance, src, services, event, health, call:logger.exception, check
## symbols ## symbols
@@ -203,11 +203,6 @@ class HealthMonitor:
# Transient states (created, restarting) — preserve current status # Transient states (created, restarting) — preserve current status
# instead of treating them as an error. The next poll will resolve. # instead of treating them as an error. The next poll will resolve.
if snapshot.container_status in ("created", "restarting"): if snapshot.container_status in ("created", "restarting"):
# A container that was already running and is now restarting has
# crashed (e.g. entrypoint failure / restart loop). Mark it failed
# so the dashboard does not keep showing it as running.
if current_status == "running":
return "error"
return current_status or "starting" return current_status or "starting"
# Unknown/unexpected state (paused, etc.) # Unknown/unexpected state (paused, etc.)
@@ -292,8 +287,9 @@ class HealthMonitor:
return return
# Skip "not_found" errors for containers that were never running # Skip "not_found" errors for containers that were never running
# (e.g. still starting, or intentionally stopped/deleted). # (e.g. still starting, or intentionally stopped/deleted).
if snapshot.container_status == "not_found" and ( if (
previous is None or previous.container_status != "running" snapshot.container_status == "not_found"
and (previous is None or previous.container_status != "running")
): ):
return return
category = "instance" category = "instance"
@@ -2,14 +2,12 @@
dir: apps/api/src/services/shared dir: apps/api/src/services/shared
## role ## role
Provides common, cross-cutting backend services used by multiple API components for security, infrastructure, and user-facing operations. Provides reusable, cross-cutting infrastructure services for security, I/O, container operations, and workspace management used throughout the API layer.
## 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
## children ## children
- apps/api/src/services/shared/.ruff_cache -
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
map: apps/api/src/services/shared/.ruff_cache/.pi-map.md
## files ## files
- __init__.py - __init__.py
- correlation.py - correlation.py
@@ -26,7 +24,5 @@ map: apps/api/src/services/shared/.pi-map.md
## workflows ## workflows
- change shared behavior - change shared behavior
read: __init__.py, correlation.py, file_service.py read: __init__.py, correlation.py, file_service.py
- explore shared subdirectories
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
+4 -6
View File
@@ -4,7 +4,7 @@ dir: apps/api/src/services/shared
index: apps/api/src/services/shared/.pi-map.index.md index: apps/api/src/services/shared/.pi-map.index.md
## role ## role
Provides common, cross-cutting backend services used by multiple API components for security, infrastructure, and user-facing operations. Provides reusable, cross-cutting infrastructure services for security, I/O, container operations, and workspace management used throughout the API layer.
## files ## files
- __init__.py | Re-exports shared service classes and functions from a services package to provide a unified public API | dep: src.services.shared.correlation, src.services.shared.file_service, src.services.shared.notification_service, src.services.shared.permission_fixer, src.services.shared.readiness_probe, src.services.shared.ssh_keys, src.services.shared.tunnel, src.services.shared.workspace_manager, correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager - __init__.py | Re-exports shared service classes and functions from a services package to provide a unified public API | dep: src.services.shared.correlation, src.services.shared.file_service, src.services.shared.notification_service, src.services.shared.permission_fixer, src.services.shared.readiness_probe, src.services.shared.ssh_keys, src.services.shared.tunnel, src.services.shared.workspace_manager, correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager
- correlation.py | Provides async correlation ID tracking via context variables and FastAPI middleware for request tracing. | exp: class:CorrelationIdMiddleware, method:dispatch(self, request: Request, call_next), call:request.headers.get, call:str, call:uuid.uuid4, call:CORRELATION_ID.set, call:call_next, call:CORRELATION_ID.reset, func:get_correlation_id() → str, call:CORRELATION_ID.get, call:str, call:uuid.uuid4 | dep: contextvars, uuid, fastapi, starlette.middleware.base, fastapi.Request, starlette.middleware.base.BaseHTTPMiddleware - correlation.py | Provides async correlation ID tracking via context variables and FastAPI middleware for request tracing. | exp: class:CorrelationIdMiddleware, method:dispatch(self, request: Request, call_next), call:request.headers.get, call:str, call:uuid.uuid4, call:CORRELATION_ID.set, call:call_next, call:CORRELATION_ID.reset, func:get_correlation_id() → str, call:CORRELATION_ID.get, call:str, call:uuid.uuid4 | dep: contextvars, uuid, fastapi, starlette.middleware.base, fastapi.Request, starlette.middleware.base.BaseHTTPMiddleware
@@ -14,11 +14,11 @@ Provides common, cross-cutting backend services used by multiple API components
- readiness_probe.py | Executes a retryable readiness probe command inside a Docker container with configurable timeout and interval | exp: func:execute_probe(container_id: str, command: str, timeout, interval) → tuple[bool, list[str]], call:asyncio.get_event_loop().time, call:logs.append, call:logger.debug, call:subprocess.run, call:result.stdout.strip, call:result.stderr.strip, call:asyncio.sleep | dep: asyncio, logging, subprocess - readiness_probe.py | Executes a retryable readiness probe command inside a Docker container with configurable timeout and interval | exp: func:execute_probe(container_id: str, command: str, timeout, interval) → tuple[bool, list[str]], call:asyncio.get_event_loop().time, call:logs.append, call:logger.debug, call:subprocess.run, call:result.stdout.strip, call:result.stderr.strip, call:asyncio.sleep | dep: asyncio, logging, subprocess
- ssh_keys.py | Decrypts and writes SSH key files to instance directories for container mounting, with optional ownership configuration and SSH config generation. | exp: func:_get_fernet() → Fernet, call:Settings, call:hashlib.sha256(settings.session_secret.encode()).digest, call:settings.session_secret.encode, call:base64.urlsafe_b64encode, call:Fernet, func:_sanitize_filename(name: str) → str, call:re.sub, call:sanitized.strip, func:prepare_ssh_key_files(instance_dir: str, ssh_key, subdir, uid, gid, key_filename, write_config) → str, call:Path, call:ssh_dir.mkdir, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:private_key_path.write_text, call:os.chmod, call:public_key_path.write_text, call:config_path.write_text, call:os.chown, call:logger.debug, call:logger.warning, call:os.getuid, call:str, func:write_ssh_config(ssh_dir: str, key_filenames: list[str], uid, gid) → None, call:Path, call:ssh_dir_path.mkdir, call:lines.append, call:"\n".join, call:config_path.write_text, call:os.chmod, call:os.chown, func:cleanup_ssh_key_files(instance_dir: str) → None, call:Path, call:ssh_dir.exists, call:ssh_dir.iterdir, call:file_path.unlink, call:ssh_dir.rmdir | dep: logging, os, re, pathlib, cryptography.fernet, src.config, base64, hashlib - ssh_keys.py | Decrypts and writes SSH key files to instance directories for container mounting, with optional ownership configuration and SSH config generation. | exp: func:_get_fernet() → Fernet, call:Settings, call:hashlib.sha256(settings.session_secret.encode()).digest, call:settings.session_secret.encode, call:base64.urlsafe_b64encode, call:Fernet, func:_sanitize_filename(name: str) → str, call:re.sub, call:sanitized.strip, func:prepare_ssh_key_files(instance_dir: str, ssh_key, subdir, uid, gid, key_filename, write_config) → str, call:Path, call:ssh_dir.mkdir, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:private_key_path.write_text, call:os.chmod, call:public_key_path.write_text, call:config_path.write_text, call:os.chown, call:logger.debug, call:logger.warning, call:os.getuid, call:str, func:write_ssh_config(ssh_dir: str, key_filenames: list[str], uid, gid) → None, call:Path, call:ssh_dir_path.mkdir, call:lines.append, call:"\n".join, call:config_path.write_text, call:os.chmod, call:os.chown, func:cleanup_ssh_key_files(instance_dir: str) → None, call:Path, call:ssh_dir.exists, call:ssh_dir.iterdir, call:file_path.unlink, call:ssh_dir.rmdir | dep: logging, os, re, pathlib, cryptography.fernet, src.config, base64, hashlib
- tunnel.py | Re-exports Docker tunnel functions from a nested module for backward compatibility. | dep: src.services.docker.tunnel - tunnel.py | Re-exports Docker tunnel functions from a nested module for backward compatibility. | dep: src.services.docker.tunnel
- workspace_manager.py | Manages workspace lifecycle operations including creation, deletion, synchronization, and migration of legacy tool instances into workspace-based repositories. | exp: class:SyncResult, class:WorkspaceHasInstancesError, method:__init__(self, instances: list[dict]) → None, call:super().__init__, call:len, class:WorkspaceManager, method:_workspace_path(self, workspace_id: uuid.UUID, repo: "GitRepository") → str, call:os.path.join, call:str, call:self._repo_directory_name, method:create(self, repo: GitRepository, user_id: uuid.UUID, name: str, branch, session) → Workspace, call:self._repo_directory_name, call:uuid.uuid4, call:os.path.join, call:str, call:logger.info, call:os.path.exists, call:logger.warning, call:shutil.rmtree, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.clone, call:logger.error, call:self._make_world_writable, call:Workspace, call:datetime.now, raise:ValueError, method:delete(self, workspace: Workspace, force, session) → None, call:self._get_instances, call:self._stop_and_delete_instance, call:os.path.exists, call:shutil.rmtree, call:logger.info, call:session.delete, raise:ValueError, raise:WorkspaceHasInstancesError, method:sync(self, workspace: Workspace, session) → SyncResult, call:logger.info, call:session.get, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.fetch, call:GitService.branch_exists_remotely, call:SyncResult, call:GitService.pull, call:self._make_world_writable, call:datetime.now, method:_make_world_writable(self, path: str) → None, call:contextlib.suppress, call:os.chmod, call:os.walk, call:os.path.join, call:os.stat, method:_get_instances(self, workspace: Workspace, session: AsyncSession) → list[ToolInstance], call:session.execute, call:select(ToolInstance).where, call:list, call:result.scalars().all, method:_stop_and_delete_instance(self, instance: ToolInstance, session: AsyncSession) → None, call:delete_tool_instance, call:logger.info, call:logger.error, method:ensure_instance_workspace(self, instance: ToolInstance, session: AsyncSession) → Workspace, call:session.get, call:self._workspace_name_exists, call:self._migrate_clone_into_workspace, call:self.create, call:session.add, call:session.commit, call:session.refresh, call:logger.info, raise:RuntimeError, method:_workspace_name_exists(self, session: AsyncSession, repo_id: uuid.UUID, name: str) → bool, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, method:_migrate_clone_into_workspace(self, instance: ToolInstance, repo: "GitRepository", session: AsyncSession, name: str) → Workspace, call:os.path.dirname, call:os.path.join, call:os.path.exists, call:uuid.uuid4, call:str, call:self._repo_directory_name, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:shutil.rmtree, call:shutil.move, call:self._make_world_writable, call:Workspace, call:datetime.now, call:session.add, call:session.flush, raise:RuntimeError | dep: contextlib, logging, os, shutil, stat, uuid, dataclasses, datetime, typing, sqlalchemy, src.models, src.services.git.git_service, src.services.shared.ssh_keys, src.utils.git_url_parser, sqlalchemy.ext.asyncio, src.services.tool.instance_service - workspace_manager.py | Manages Git workspace lifecycle operations including creation, deletion, sync, and migration of legacy tool instances into workspace-bound repositories. | exp: class:SyncResult, class:WorkspaceHasInstancesError, method:__init__(self, instances: list[dict]) → None, call:super().__init__, call:len, class:WorkspaceManager, method:_workspace_path(self, repo_id: uuid.UUID, name: str) → str, call:os.path.join, call:str, method:create(self, repo: GitRepository, user_id: uuid.UUID, name: str, branch, session) → Workspace, call:self._workspace_path, call:os.path.dirname, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:logger.info, call:os.path.exists, call:logger.warning, call:shutil.rmtree, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.clone, call:self._make_world_writable, call:Workspace, call:datetime.now, raise:ValueError, method:delete(self, workspace: Workspace, force, session) → None, call:self._get_instances, call:self._stop_and_delete_instance, call:os.path.exists, call:shutil.rmtree, call:logger.info, call:session.delete, raise:ValueError, raise:WorkspaceHasInstancesError, method:sync(self, workspace: Workspace, session) → SyncResult, call:logger.info, call:session.get, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.fetch, call:GitService.branch_exists_remotely, call:SyncResult, call:GitService.pull, call:self._make_world_writable, call:datetime.now, method:_make_world_writable(self, path: str) → None, call:contextlib.suppress, call:os.chmod, call:os.walk, call:os.path.join, call:os.stat, method:_get_instances(self, workspace: Workspace, session: AsyncSession) → list[ToolInstance], call:session.execute, call:select(ToolInstance).where, call:list, call:result.scalars().all, method:_stop_and_delete_instance(self, instance: ToolInstance, session: AsyncSession) → None, call:delete_tool_instance, call:logger.info, call:logger.error, method:ensure_instance_workspace(self, instance: ToolInstance, session: AsyncSession) → Workspace, call:session.get, call:self._workspace_name_exists, call:self._migrate_clone_into_workspace, call:self.create, call:session.add, call:session.commit, call:session.refresh, call:logger.info, raise:RuntimeError, method:_workspace_name_exists(self, session: AsyncSession, repo_id: uuid.UUID, name: str) → bool, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, method:_migrate_clone_into_workspace(self, instance: ToolInstance, repo: "GitRepository", session: AsyncSession, name: str) → Workspace, call:os.path.dirname, call:os.path.join, call:os.path.exists, call:self._workspace_path, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:shutil.rmtree, call:shutil.move, call:self._make_world_writable, call:Workspace, call:datetime.now, call:session.add, call:session.flush, raise:RuntimeError | dep: contextlib, logging, os, shutil, stat, uuid, dataclasses, datetime, typing, sqlalchemy, src.models, src.services.git.git_service, src.services.shared.ssh_keys, sqlalchemy.ext.asyncio, src.services.tool.instance_service
## arch ## arch
Modular utility services following singleton and async patterns, with Docker/container integration, filesystem sandboxing, context-based request tracing, and strict resource ownership isolation. Modular utility services pattern with async singletons, context variables for request tracing, defensive security (path traversal/ownership isolation), Docker exec abstraction, and Git-backed workspace lifecycle management.
## tags ## tags
error, call:self., get, workspace, src, key, call:str, call:session.execute error, workspace, call:self., get, key, src, call:session.execute, call:ssh
## symbols ## symbols
- CorrelationIdMiddleware - CorrelationIdMiddleware
- FileEntry - FileEntry
@@ -31,7 +31,5 @@ error, call:self., get, workspace, src, key, call:str, call:session.execute
## workflows ## workflows
- change shared behavior - change shared behavior
read: __init__.py, correlation.py, file_service.py read: __init__.py, correlation.py, file_service.py
- explore shared subdirectories
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
@@ -17,7 +17,6 @@ from sqlalchemy import select
from src.models import Workspace from src.models import Workspace
from src.services.git.git_service import GitService from src.services.git.git_service import GitService
from src.services.shared.ssh_keys import _get_fernet from src.services.shared.ssh_keys import _get_fernet
from src.utils.git_url_parser import extract_base_repo_url
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,32 +47,9 @@ class WorkspaceManager:
BASE_PATH = "/data/working-copies" BASE_PATH = "/data/working-copies"
@staticmethod def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
def _repo_directory_name(repo: "GitRepository") -> str: """Return the filesystem path for a workspace."""
"""Return the directory name git would create for a standard clone. return os.path.join(self.BASE_PATH, str(repo_id), name)
Prefers the name parsed from the remote URL and falls back to the
user-provided repository name when no remote URL is available.
"""
if repo.remote_url:
base_url = extract_base_repo_url(repo.remote_url) or repo.remote_url
name = base_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
if name:
return name
return repo.name
def _workspace_path(self, workspace_id: uuid.UUID, repo: "GitRepository") -> str:
"""Return the filesystem path for a workspace.
Layout: /data/working-copies/{workspace_id}/{repo_name}/
The workspace_id prevents collisions between workspaces, and the
repo_name matches the directory git clone naturally creates.
"""
return os.path.join(
self.BASE_PATH, str(workspace_id), self._repo_directory_name(repo)
)
async def create( async def create(
self, self,
@@ -98,32 +74,24 @@ class WorkspaceManager:
Raises: Raises:
RuntimeError: If git clone fails. RuntimeError: If git clone fails.
""" """
path = self._workspace_path(repo.id, name)
parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
with contextlib.suppress(OSError):
os.chmod(parent, 0o777)
logger.info(
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
)
if not repo.remote_url: if not repo.remote_url:
raise ValueError("Repository has no remote URL") raise ValueError("Repository has no remote URL")
repo_dir_name = self._repo_directory_name(repo)
workspace_id = uuid.uuid4()
parent_path = os.path.join(self.BASE_PATH, str(workspace_id))
expected_path = os.path.join(parent_path, repo_dir_name)
logger.info(
"Creating workspace: id=%s name=%s repo=%s branch=%s target=%s",
workspace_id,
name,
repo.id,
branch,
expected_path,
)
# Remove stale directory from previous failed/aborted clone # Remove stale directory from previous failed/aborted clone
if os.path.exists(expected_path): if os.path.exists(path):
logger.warning("Removing stale workspace directory: %s", expected_path) logger.warning("Removing stale workspace directory: %s", path)
shutil.rmtree(expected_path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
os.makedirs(parent_path, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
with contextlib.suppress(OSError):
os.chmod(parent_path, 0o777)
# Load SSH key if repo has one # Load SSH key if repo has one
ssh_key = None ssh_key = None
@@ -140,33 +108,19 @@ class WorkspaceManager:
ssh_key_obj.private_key_encrypted.encode() ssh_key_obj.private_key_encrypted.encode()
).decode() ).decode()
try: await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
await GitService.clone( self._make_world_writable(path)
repo.remote_url, branch, expected_path, ssh_key=ssh_key
)
except Exception as exc:
logger.error(
"Git clone failed for workspace %s (repo=%s, url=%s): %s",
workspace_id,
repo.id,
repo.remote_url,
exc,
)
raise
self._make_world_writable(expected_path)
workspace = Workspace( workspace = Workspace(
id=workspace_id,
name=name, name=name,
repo_id=repo.id, repo_id=repo.id,
user_id=user_id, user_id=user_id,
branch=branch, branch=branch,
path=expected_path, path=path,
status="ready", status="ready",
last_sync_at=datetime.now(), last_sync_at=datetime.now(),
) )
logger.info("Workspace created: %s at %s", workspace.id, workspace.path) logger.info("Workspace created: %s", workspace.id)
return workspace return workspace
async def delete( async def delete(
@@ -421,30 +375,24 @@ class WorkspaceManager:
if not os.path.exists(clone_path): if not os.path.exists(clone_path):
raise RuntimeError(f"Clone path not found: {clone_path}") raise RuntimeError(f"Clone path not found: {clone_path}")
workspace_id = uuid.uuid4() path = self._workspace_path(repo.id, name)
parent_path = os.path.join(self.BASE_PATH, str(workspace_id)) parent = os.path.dirname(path)
repo_dir_name = self._repo_directory_name(repo) os.makedirs(parent, exist_ok=True)
target_path = os.path.join(parent_path, repo_dir_name)
os.makedirs(parent_path, exist_ok=True)
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
os.chmod(parent_path, 0o777) os.chmod(parent, 0o777)
if os.path.exists(target_path): if os.path.exists(path):
shutil.rmtree(target_path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
# Move the existing clone into the repo-named subdirectory so the shutil.move(clone_path, path)
# workspace path matches the natural git clone layout. self._make_world_writable(path)
shutil.move(clone_path, target_path)
self._make_world_writable(target_path)
workspace = Workspace( workspace = Workspace(
id=workspace_id,
name=name, name=name,
repo_id=repo.id, repo_id=repo.id,
user_id=instance.owner_id, user_id=instance.owner_id,
branch=instance.branch or "main", branch=instance.branch or "main",
path=target_path, path=path,
status="ready", status="ready",
last_sync_at=datetime.now(), last_sync_at=datetime.now(),
) )
+2 -6
View File
@@ -2,14 +2,12 @@
dir: apps/api/src/services/tool dir: apps/api/src/services/tool
## role ## role
Provides infrastructure orchestration for isolated tool instances, managing their complete lifecycle from provisioning to teardown. Provides backend infrastructure for provisioning and managing isolated development tool instances with their dependencies and network access.
## 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
## children ## children
- apps/api/src/services/tool/.ruff_cache -
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
map: apps/api/src/services/tool/.ruff_cache/.pi-map.md
## files ## files
- instance_service.py - instance_service.py
## links ## links
@@ -18,7 +16,5 @@ map: apps/api/src/services/tool/.pi-map.md
## workflows ## workflows
- change tool behavior - change tool behavior
read: instance_service.py read: instance_service.py
- explore tool subdirectories
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
File diff suppressed because one or more lines are too long
+34 -243
View File
@@ -5,24 +5,16 @@ import contextlib
import glob as glob_module import glob as glob_module
import logging import logging
import os import os
import shutil
import subprocess import subprocess
import uuid import uuid
from datetime import datetime from datetime import datetime
import httpx
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ( from src.models import ConfigProfile, GitRepository, Project, SSHKey, ToolInstance, ToolType
ConfigProfile,
GitRepository,
Project,
SSHKey,
ToolInstance,
ToolType,
Workspace,
)
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
from src.services.git.clone import check_dirty_state, clone_repository from src.services.git.clone import check_dirty_state, clone_repository
from src.services.config.config_profile_resolver import ( from src.services.config.config_profile_resolver import (
@@ -72,40 +64,15 @@ from src.services.shared.permission_fixer import (
apply_ssh_permissions, apply_ssh_permissions,
) )
from src.services.shared.readiness_probe import execute_probe from src.services.shared.readiness_probe import execute_probe
from src.services.shared.ssh_keys import prepare_ssh_key_files from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
from src.services.instance.event_bus import InstanceEventBus from src.services.instance.event_bus import InstanceEventBus
from src.services.instance.lifecycle_hooks import publish_lifecycle_event from src.services.instance.lifecycle_hooks import publish_lifecycle_event
from src.utils.git_url_parser import extract_base_repo_url from src.auth.dependencies import _get_owned_project, _get_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus() _event_bus = InstanceEventBus()
def _get_repository_mount_name(
repo: GitRepository,
workspace: "Workspace | None" = None,
) -> str:
"""Return the directory name the repository should appear under in the container.
When a workspace exists, the on-disk layout is
``/data/working-copies/{workspace_id}/{repo_name}/``, so the repo-named
directory is already available as the basename of ``workspace.path``. For
legacy repo-only instances we fall back to parsing the remote URL like a
standard ``git clone`` would, then to the user-provided repository name.
"""
if workspace is not None and workspace.path:
return os.path.basename(os.path.normpath(workspace.path))
if repo.remote_url:
base_url = extract_base_repo_url(repo.remote_url) or repo.remote_url
name = base_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
if name:
return name
return repo.name
def _chown_path(path: str, uid: int, gid: int) -> None: def _chown_path(path: str, uid: int, gid: int) -> None:
"""Recursively chown a path, suppressing permission errors.""" """Recursively chown a path, suppressing permission errors."""
try: try:
@@ -140,79 +107,6 @@ def _chown_staged_mounts(
_chown_path(source, uid, gid) _chown_path(source, uid, gid)
def _relative_under(parent: str, child: str) -> str | None:
"""Return the relative path of ``child`` under ``parent`` if it is inside.
Returns ``""`` when the paths are equal. Returns ``None`` when ``child``
is not under ``parent``.
"""
parent = os.path.normpath(parent)
child = os.path.normpath(child)
if child == parent:
return ""
prefix = parent + os.sep
if child.startswith(prefix):
return child[len(prefix) :]
return None
def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict],
git_mount_volumes: list[dict],
) -> list[dict]:
"""Merge profile file mounts into overlapping git-mount sources.
When a config profile mounts static files to the same directory as a
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
would mask the cloned repository. Instead, copy the profile files into
the git-mount source directory so the container sees both sets of files
through a single bind mount.
Profile mounts whose target is a child of a git-mount target are copied
into the corresponding subdirectory. Mounts that do not overlap are
returned unchanged.
"""
remaining: list[dict] = []
for pvol in profile_mounts:
p_source = pvol.get("source", "")
p_target = pvol.get("target", "")
if not p_source or not os.path.exists(p_source):
remaining.append(pvol)
continue
merged = False
for gvol in git_mount_volumes:
g_source = gvol.get("source", "")
g_target = gvol.get("target", "")
if not g_source or not os.path.isdir(g_source):
continue
rel = _relative_under(g_target, p_target)
if rel is None:
continue
dst = os.path.join(g_source, rel) if rel else g_source
if os.path.isdir(p_source):
shutil.copytree(p_source, dst, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(p_source, dst)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
)
merged = True
break
if not merged:
remaining.append(pvol)
return remaining
async def resolve_git_mounts( async def resolve_git_mounts(
session: AsyncSession, session: AsyncSession,
resolved: ResolvedProfile, resolved: ResolvedProfile,
@@ -245,7 +139,7 @@ async def resolve_git_mounts(
if isinstance(result, Exception): if isinstance(result, Exception):
logger.warning("Git mount failed: %s", result) logger.warning("Git mount failed: %s", result)
continue continue
if isinstance(result, list): if result:
volume_mounts.extend(result) volume_mounts.extend(result)
return volume_mounts return volume_mounts
@@ -278,12 +172,7 @@ def clone_git_repo(
""" """
import hashlib import hashlib
# Include the branch in the hash so different branches of the same repo url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
# get separate clone directories and cannot race each other.
branch_segment = branch or "default"
url_hash = hashlib.md5(
f"{remote_url}:{branch_segment}".encode()
).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo" repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}") clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
repo_path = os.path.join(clone_dir, "repo-clone") repo_path = os.path.join(clone_dir, "repo-clone")
@@ -382,20 +271,8 @@ def resolve_git_mount_mappings(
# Single match: mount directly to target_path # Single match: mount directly to target_path
mount_target = final_target mount_target = final_target
else: else:
# Multiple matches: append the path relative to the glob's base # Multiple matches: append relative path to target
# directory so `packages/*` → `/app/packages` yields rel_path = os.path.relpath(matched_path, repo_path)
# `/app/packages/api` instead of `/app/packages/packages/api`.
first_glob_idx = min(
(source_path.find(c) for c in "*?[" if c in source_path),
default=len(source_path),
)
base_relative = os.path.dirname(source_path[: first_glob_idx + 1])
base_full = (
os.path.join(repo_path, base_relative)
if base_relative
else repo_path
)
rel_path = os.path.relpath(matched_path, base_full)
mount_target = os.path.join(final_target, rel_path) mount_target = os.path.join(final_target, rel_path)
volume_mounts.append( volume_mounts.append(
@@ -448,14 +325,7 @@ async def resolve_single_git_mount(
repo_path = await asyncio.to_thread( repo_path = await asyncio.to_thread(
clone_git_repo, remote_url, branch, instance_dir clone_git_repo, remote_url, branch, instance_dir
) )
except Exception as exc: except Exception:
logger.warning(
"Git mount clone failed for %s (branch=%s): %s",
remote_url,
branch,
exc,
exc_info=True,
)
return [] return []
# Resolve all mappings from the cloned repo # Resolve all mappings from the cloned repo
@@ -907,6 +777,7 @@ def ensure_backend_network_in_compose(compose_path: str) -> None:
logger.info("Injected backend network '%s' into compose file", network_name) logger.info("Injected backend network '%s' into compose file", network_name)
async def prepare_manifest_instance( async def prepare_manifest_instance(
session: AsyncSession, session: AsyncSession,
instance: ToolInstance, instance: ToolInstance,
@@ -1012,27 +883,12 @@ async def prepare_manifest_instance(
# The actual resolution happens in resolve_git_mounts; we store placeholder # The actual resolution happens in resolve_git_mounts; we store placeholder
git_mount_vars[f"GIT_MOUNT_{ref}"] = "" git_mount_vars[f"GIT_MOUNT_{ref}"] = ""
# Use the repository name for the workspace/repo mount target, not the
# directory name of a workspace/clone path (which may be "main" or similar).
# Prefer the actual on-disk workspace directory name when a workspace is
# mounted, otherwise fall back to parsing the remote URL like git clone.
repo = await session.get(GitRepository, instance.repository_id)
workspace: Workspace | None = None
if instance.workspace_id:
workspace = await session.get(Workspace, instance.workspace_id)
repo_name = (
_get_repository_mount_name(repo, workspace)
if repo
else os.path.basename(os.path.normpath(repo_path))
)
variables = { variables = {
"IMAGE_TAG": image_tag, "IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance.name.lower(), "INSTANCE_NAME": instance.name.lower(),
"INSTANCE_DIR": instance_dir, "INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path, "WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path, "REPO_PATH": repo_path,
"REPO_NAME": repo_name,
"WORKSPACE_NAME": repo_name,
"SSH_PATH": ssh_path, "SSH_PATH": ssh_path,
"TOOL_PORT": instance.port or 0, "TOOL_PORT": instance.port or 0,
"EXTRA_ENV": env_vars, "EXTRA_ENV": env_vars,
@@ -1062,6 +918,8 @@ async def prepare_manifest_instance(
return image_tag, compose_content, manifest, home_dir return image_tag, compose_content, manifest, home_dir
async def create_tool_instance( async def create_tool_instance(
session: AsyncSession, session: AsyncSession,
user_id: uuid.UUID, user_id: uuid.UUID,
@@ -1185,29 +1043,15 @@ async def create_tool_instance(
else "" else ""
) )
home_dir = tool_type.home_directory or "/home/user" compose_content = f"""version: "3.8"\nservices:\n app:\n image: {image_tag}\n container_name: {instance_name.lower()}\n stdin_open: true\n tty: true\n{ports_section} volumes:\n - {repo_path}:/workspace\n restart: unless-stopped\n"""
mount_name = _get_repository_mount_name(repo, workspace)
workspace_target = f"{home_dir}/{mount_name}"
compose_content = f"""version: "3.8"\nservices:
app:
image: {image_tag}
container_name: {instance_name.lower()}
stdin_open: true
tty: true
{ports_section} environment:
- HOME={home_dir}
volumes:
- {repo_path}:{workspace_target}
working_dir: {workspace_target}
restart: "no"
"""
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest": elif tool_type.definition_type == "manifest":
from src.models import ToolDefinitionManifest from src.models import ToolDefinitionManifest
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id
)
if not manifest_def: if not manifest_def:
raise RuntimeError("Manifest definition not found for this tool type") raise RuntimeError("Manifest definition not found for this tool type")
@@ -1217,21 +1061,20 @@ async def create_tool_instance(
ToolDefinitionManifest, manifest_def.base_definition_id ToolDefinitionManifest, manifest_def.base_definition_id
) )
if base_def: if base_def:
manifest = resolve_base(deep_merge(dict(base_def.manifest), manifest)) manifest = resolve_base(
deep_merge(dict(base_def.manifest), manifest)
)
image_tag = compute_image_tag(tool_type.name, manifest) image_tag = compute_image_tag(tool_type.name, manifest)
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a # Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
# deprecated alias for backward compatibility with older templates. # deprecated alias for backward compatibility with older templates.
mount_name = _get_repository_mount_name(repo, workspace)
variables = { variables = {
"IMAGE_TAG": image_tag, "IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(), "INSTANCE_NAME": instance_name.lower(),
"INSTANCE_DIR": instance_dir, "INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path, "WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path, "REPO_PATH": repo_path,
"REPO_NAME": mount_name,
"WORKSPACE_NAME": mount_name,
"SSH_PATH": "", "SSH_PATH": "",
"TOOL_PORT": tool_port, "TOOL_PORT": tool_port,
"EXTRA_ENV": {}, "EXTRA_ENV": {},
@@ -1252,10 +1095,10 @@ async def create_tool_instance(
"TOOL_PORT": tool_port, "TOOL_PORT": tool_port,
"USER_ID": str(user_id), "USER_ID": str(user_id),
"PROJECT_ID": str(project_id), "PROJECT_ID": str(project_id),
"WORKSPACE_NAME": _get_repository_mount_name(repo, workspace),
"HOME_DIRECTORY": tool_type.home_directory or "/home/user",
} }
compose_content = render_compose_template(tool_type.compose_template, variables) compose_content = render_compose_template(
tool_type.compose_template, variables
)
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
@@ -1339,7 +1182,7 @@ async def start_tool_instance(
# Fetch tool type early to determine home directory and container user # Fetch tool type early to determine home directory and container user
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
home_dir = tool_type.home_directory if tool_type and tool_type.home_directory else "/root" home_dir = "/root"
container_uid = 0 container_uid = 0
container_gid = 0 container_gid = 0
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
@@ -1357,22 +1200,16 @@ async def start_tool_instance(
deep_merge(dict(base_def.manifest), manifest) deep_merge(dict(base_def.manifest), manifest)
) )
home_dir = get_manifest_home_dir(manifest) home_dir = get_manifest_home_dir(manifest)
runtime = manifest.get("runtime", {})
if runtime.get("working_dir"):
working_directory = expand_container_path(
runtime["working_dir"], home_dir
)
user_cfg = manifest.get("user") user_cfg = manifest.get("user")
if user_cfg: if user_cfg:
container_uid = user_cfg.get("uid", 0) container_uid = user_cfg.get("uid", 0)
container_gid = user_cfg.get("gid", 0) container_gid = user_cfg.get("gid", 0)
logger.debug( logger.debug(
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s, working_directory=%s", "Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s",
instance.id, instance.id,
container_uid, container_uid,
container_gid, container_gid,
home_dir, home_dir,
working_directory,
) )
# Apply selected config profile if any # Apply selected config profile if any
@@ -1385,37 +1222,27 @@ async def start_tool_instance(
profile_env, profile_files, profile_mounts, profile_hints = ( profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved, home_dir) apply_resolved_profile(instance_dir, resolved, home_dir)
) )
# Profile hints override the manifest/tool defaults, and git mounts
# need the final working directory to resolve relative target paths.
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
env_vars.update(profile_env) env_vars.update(profile_env)
config_files.update(profile_files) config_files.update(profile_files)
extra_volumes.extend(profile_mounts)
git_mount_volumes = await resolve_git_mounts( git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir session, resolved, instance_dir, working_directory, home_dir
) )
# Stack static file mounts on top of git repo mounts so they do
# not mask each other when they target the same directory.
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
extra_volumes.extend(stacked_profile_mounts)
extra_volumes.extend(git_mount_volumes) extra_volumes.extend(git_mount_volumes)
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = profile_hints["working_directory"]
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
logger.debug( logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)", "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
resolved.profile_name, resolved.profile_name,
instance.id, instance.id,
len(profile_env), len(profile_env),
len(profile_files), len(profile_files),
len(profile_mounts), len(profile_mounts),
len(git_mount_volumes), len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts),
) )
except ConfigProfileCycleError as exc: except ConfigProfileCycleError as exc:
logger.error( logger.error(
@@ -1838,41 +1665,6 @@ async def start_tool_instance(
logger.info("Readiness probe succeeded for instance %s", instance.id) logger.info("Readiness probe succeeded for instance %s", instance.id)
# Final stability check: the container must still be running after all
# post-start setup. If it has already exited/restarted, mark it failed now
# instead of optimistically reporting "running".
if instance.container_id:
final_check = get_container_status(instance.container_id)
if final_check["status"] != "running":
error_msg = (
f"Container stopped during startup: status={final_check['status']}"
)
if final_check["exit_code"] is not None:
error_msg += f", exit_code={final_check['exit_code']}"
logs = get_container_logs(instance.container_id, tail=50)
instance.status = "error"
await session.commit()
await publish_lifecycle_event(
event_bus=_event_bus,
session=session,
instance=instance,
event_type="instance.error",
created_by=user_id,
status="error",
message=error_msg,
metadata={
"exit_code": final_check["exit_code"],
"error_type": "container",
},
)
logger.error(
"Instance %s container stopped during startup: %s\nLogs:\n%s",
instance.id,
error_msg,
logs,
)
return {"status": "error", "error": error_msg, "logs": logs}
instance.status = "running" instance.status = "running"
await session.commit() await session.commit()
await publish_lifecycle_event( await publish_lifecycle_event(
@@ -2301,9 +2093,7 @@ async def stop_tool_instance(
try: try:
stop_tunnel(instance.name) stop_tunnel(instance.name)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
"Failed to stop tunnel for instance %s: %s", instance.id, exc
)
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "stop") execute_compose_command(instance.compose_path, "stop")
@@ -2345,3 +2135,4 @@ async def rename_tool_instance(
await session.commit() await session.commit()
await session.refresh(instance) await session.refresh(instance)
return instance return instance
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests dir: apps/api/tests
## role ## role
Provides shared test infrastructure and fixtures for the API application's test suite. Provides shared test infrastructure and fixtures for the FastAPI API application.
## 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
+2 -2
View File
@@ -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 the API application's test suite. Provides shared test infrastructure and fixtures for the FastAPI API application.
## files ## files
- conftest.py | Provides shared pytest fixtures for testing a FastAPI application with async SQLite database, authenticated clients, and test data setup. | 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 testing a FastAPI application with async SQLite database, authenticated clients, and test data setup. | 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
## arch ## arch
Pytest fixture-based testing architecture with async SQLite test database, dependency injection overrides, and authenticated client factories using FastAPI's TestClient. Pytest fixture-based testing architecture using async SQLite in-memory database, dependency injection overrides, and async HTTP client setup for isolated integration tests.
## 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
+1 -2
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests/integration dir: apps/api/tests/integration
## role ## role
Integration and unit test suite for the API backend, covering authentication, CRUD APIs, workspace operations, git integration, notifications, and database model validation against real PostgreSQL and mocked dependencies. Integration test suite for the API backend, covering authentication, workspaces, projects, notifications, configuration, and database models with real PostgreSQL and git repositories.
## 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
@@ -21,7 +21,6 @@ map: apps/api/tests/.pi-map.md
- test_projects_api.py - test_projects_api.py
- test_seed.py - test_seed.py
- test_ssh_keys_api.py - test_ssh_keys_api.py
- test_tool_instance_lifecycle.py
- test_tool_types_api.py - test_tool_types_api.py
- test_tool_types_api_extended.py - test_tool_types_api_extended.py
- test_users_api.py - test_users_api.py
+2 -3
View File
@@ -4,7 +4,7 @@ dir: apps/api/tests/integration
index: apps/api/tests/integration/.pi-map.index.md index: apps/api/tests/integration/.pi-map.index.md
## role ## role
Integration and unit test suite for the API backend, covering authentication, CRUD APIs, workspace operations, git integration, notifications, and database model validation against real PostgreSQL and mocked dependencies. Integration test suite for the API backend, covering authentication, workspaces, projects, notifications, configuration, and database models with real PostgreSQL and git repositories.
## files ## files
- __init__.py | Empty file with no functionality - __init__.py | Empty file with no functionality
- test_auth_api.py | Integration tests for authentication API endpoints using a real PostgreSQL database | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_auth_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_login_redirects_to_authentik_authorize_endpoint() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_401_without_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_user_with_valid_session() → None, call:_prepare_auth_test_db, call:_insert_test_user, call:_load_app, call:Settings, call:create_session_cookie, call:TestClient, call:client.get, call:response.json, func:test_logout_clears_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.post, call:response.headers.get | dep: uuid, asyncio, importlib, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user.user, asyncpg, fastapi - test_auth_api.py | Integration tests for authentication API endpoints using a real PostgreSQL database | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_auth_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_login_redirects_to_authentik_authorize_endpoint() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_401_without_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_user_with_valid_session() → None, call:_prepare_auth_test_db, call:_insert_test_user, call:_load_app, call:Settings, call:create_session_cookie, call:TestClient, call:client.get, call:response.json, func:test_logout_clears_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.post, call:response.headers.get | dep: uuid, asyncio, importlib, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user.user, asyncpg, fastapi
@@ -18,7 +18,6 @@ Integration and unit test suite for the API backend, covering authentication, CR
- test_projects_api.py | Integration tests for a FastAPI projects API endpoint, verifying authentication, CRUD operations, and ownership-based authorization against a real PostgreSQL database. | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_project(project_id: str, owner_id: str, name) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:Project, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:Project, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_create_project_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.post, func:test_create_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_list_projects_returns_only_owned_projects() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, func:test_update_project_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch, func:test_update_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch, call:response.json, func:test_delete_project_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, func:test_delete_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, func:test_set_default_ssh_key_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.project.project, src.models.user.user, asyncpg, importlib, fastapi, src.database, src.api.user.auth, src.api.project.projects, src.main - test_projects_api.py | Integration tests for a FastAPI projects API endpoint, verifying authentication, CRUD operations, and ownership-based authorization against a real PostgreSQL database. | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_project(project_id: str, owner_id: str, name) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:Project, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:Project, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_create_project_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.post, func:test_create_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_list_projects_returns_only_owned_projects() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, func:test_update_project_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch, func:test_update_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch, call:response.json, func:test_delete_project_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, func:test_delete_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, func:test_set_default_ssh_key_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.project.project, src.models.user.user, asyncpg, importlib, fastapi, src.database, src.api.user.auth, src.api.project.projects, src.main
- test_seed.py | Tests deterministic seed user generation and database seeding functionality for development environments. | exp: func:test_build_seed_user_returns_deterministic_payload() → None, call:build_seed_user, func:test_seed_database_creates_development_user(db_session: AsyncSession) → None, call:seed_database, call:db_session.scalar, call:select(User).where | dep: pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.models.user, src.scripts.seed - test_seed.py | Tests deterministic seed user generation and database seeding functionality for development environments. | exp: func:test_build_seed_user_returns_deterministic_payload() → None, call:build_seed_user, func:test_seed_database_creates_development_user(db_session: AsyncSession) → None, call:seed_database, call:db_session.scalar, call:select(User).where | dep: pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.models.user, src.scripts.seed
- test_ssh_keys_api.py | Integration tests verifying SSH key API endpoints require authentication | exp: func:test_create_ssh_key_requires_authentication(test_client: TestClient) → None, call:test_client.post, func:test_list_ssh_keys_requires_authentication(test_client: TestClient) → None, call:test_client.get | dep: pytest, fastapi.testclient - test_ssh_keys_api.py | Integration tests verifying SSH key API endpoints require authentication | exp: func:test_create_ssh_key_requires_authentication(test_client: TestClient) → None, call:test_client.post, func:test_list_ssh_keys_requires_authentication(test_client: TestClient) → None, call:test_client.get | dep: pytest, fastapi.testclient
- test_tool_instance_lifecycle.py | Integration test placeholder for verifying Docker availability and container home directory lifecycle behavior | exp: func:_docker_available() → bool, call:subprocess.run, func:test_docker_available_placeholder() → None, call:_docker_available | dep: subprocess, pytest
- test_tool_types_api.py | Integration tests for a FastAPI tool types REST API endpoint using a real PostgreSQL database. | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, call:datetime.now, call:timedelta, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_tool_type(tool_type_id: str, name: str, display_name: str, compose_template: str, created_by_id) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_list_tool_types_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.get, func:test_list_tool_types_returns_all_types() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, call:next, func:test_get_tool_type_by_id() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, func:test_get_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, func:test_create_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_create_tool_type_duplicate_name() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_invalid_yaml() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_services() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_required_variable() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_update_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, call:response.json, func:test_update_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, func:test_delete_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, call:client.get, func:test_delete_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.tool.tool_type, src.models.user.user, asyncpg, importlib, fastapi - test_tool_types_api.py | Integration tests for a FastAPI tool types REST API endpoint using a real PostgreSQL database. | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, call:datetime.now, call:timedelta, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_tool_type(tool_type_id: str, name: str, display_name: str, compose_template: str, created_by_id) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_list_tool_types_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.get, func:test_list_tool_types_returns_all_types() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, call:next, func:test_get_tool_type_by_id() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, func:test_get_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, func:test_create_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_create_tool_type_duplicate_name() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_invalid_yaml() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_services() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_required_variable() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_update_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, call:response.json, func:test_update_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, func:test_delete_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, call:client.get, func:test_delete_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.tool.tool_type, src.models.user.user, asyncpg, importlib, fastapi
- test_tool_types_api_extended.py | Integration tests for a FastAPI tool types API endpoint covering CRUD operations with extended fields like dockerfile templates, readiness probes, startup commands, and validation. | exp: class:TestToolTypesAPIExtended, method:test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_validate_tool_type_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json, method:test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, call:str, method:test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_update_tool_type_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json | dep: pytest, fastapi.testclient, fastapi.testclient.TestClient - test_tool_types_api_extended.py | Integration tests for a FastAPI tool types API endpoint covering CRUD operations with extended fields like dockerfile templates, readiness probes, startup commands, and validation. | exp: class:TestToolTypesAPIExtended, method:test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_validate_tool_type_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json, method:test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, call:str, method:test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_update_tool_type_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json | dep: pytest, fastapi.testclient, fastapi.testclient.TestClient
- test_users_api.py | Integration tests for user profile API endpoints using a real PostgreSQL database | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_users_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_create_auth_cookie(user_id: str) → str, call:Settings, call:create_session_cookie, func:test_get_profile_returns_401_without_cookie() → None, call:_prepare_users_test_db, call:_load_app, call:TestClient, call:client.get, func:test_get_profile_returns_user_data() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.get, call:response.json, func:test_update_profile_changes_name_and_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, call:response.json, func:test_update_profile_rejects_empty_name() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_update_profile_rejects_invalid_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_upload_avatar_updates_avatar_url() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, call:response.json, call:data["avatar_url"].startswith, func:test_upload_avatar_rejects_invalid_file_type() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, func:test_upload_avatar_rejects_oversized_file() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO | dep: uuid, datetime, asyncio, io, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user.user, asyncpg, importlib, fastapi, src.database, src.api.user.users, src.main - test_users_api.py | Integration tests for user profile API endpoints using a real PostgreSQL database | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_users_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_create_auth_cookie(user_id: str) → str, call:Settings, call:create_session_cookie, func:test_get_profile_returns_401_without_cookie() → None, call:_prepare_users_test_db, call:_load_app, call:TestClient, call:client.get, func:test_get_profile_returns_user_data() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.get, call:response.json, func:test_update_profile_changes_name_and_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, call:response.json, func:test_update_profile_rejects_empty_name() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_update_profile_rejects_invalid_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_upload_avatar_updates_avatar_url() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, call:response.json, call:data["avatar_url"].startswith, func:test_upload_avatar_rejects_invalid_file_type() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, func:test_upload_avatar_rejects_oversized_file() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO | dep: uuid, datetime, asyncio, io, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user.user, asyncpg, importlib, fastapi, src.database, src.api.user.users, src.main
@@ -27,7 +26,7 @@ Integration and unit test suite for the API backend, covering authentication, CR
- test_workspace_instances.py | Integration tests for FastAPI workspace instance endpoints (list and create) using an authenticated test client and mocked database fixtures. | exp: class:TestListWorkspaceInstances, method:test_list_empty(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:authenticated_client.get, call:response.json, method:test_list_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_workspace_with_tool_type), call:ToolInstance, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_create_instance, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspaceInstance, method:test_create_instance(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:ToolInstance, call:uuid.uuid4, call:datetime.now, call:patch, call:authenticated_client.post, call:str, call:response.json, func:_get_user_id(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_workspace_with_tool_type(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh | dep: asyncio, tempfile, uuid, datetime, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.auth.session, src.config, src.api.workspace.workspace_instances - test_workspace_instances.py | Integration tests for FastAPI workspace instance endpoints (list and create) using an authenticated test client and mocked database fixtures. | exp: class:TestListWorkspaceInstances, method:test_list_empty(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:authenticated_client.get, call:response.json, method:test_list_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_workspace_with_tool_type), call:ToolInstance, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_create_instance, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspaceInstance, method:test_create_instance(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:ToolInstance, call:uuid.uuid4, call:datetime.now, call:patch, call:authenticated_client.post, call:str, call:response.json, func:_get_user_id(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_workspace_with_tool_type(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh | dep: asyncio, tempfile, uuid, datetime, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.auth.session, src.config, src.api.workspace.workspace_instances
- test_workspaces_api.py | Integration tests for workspace API endpoints including list, create, delete, and sync operations with repository/project scoping | exp: class:TestListWorkspaces, method:test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.get, call:response.json, method:test_list_with_workspaces(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspace, method:test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository), call:Workspace, call:uuid.uuid4, call:patch.object, call:authenticated_client.post, call:response.json, call:mock_create.assert_called_once, method:test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.post, call:response.json, method:test_create_duplicate_name(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:patch.object, call:Exception, call:authenticated_client.post, class:TestDeleteWorkspace, method:test_delete_without_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, call:response.json, method:test_delete_with_instances_force(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, class:TestSyncWorkspace, method:test_sync_success(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, method:test_sync_branch_deleted(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, func:_get_user_id_from_client(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id_from_client, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh | dep: asyncio, uuid, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.services.shared.workspace_manager, src.auth.session, src.config, fastapi, sqlalchemy - test_workspaces_api.py | Integration tests for workspace API endpoints including list, create, delete, and sync operations with repository/project scoping | exp: class:TestListWorkspaces, method:test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.get, call:response.json, method:test_list_with_workspaces(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspace, method:test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository), call:Workspace, call:uuid.uuid4, call:patch.object, call:authenticated_client.post, call:response.json, call:mock_create.assert_called_once, method:test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.post, call:response.json, method:test_create_duplicate_name(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:patch.object, call:Exception, call:authenticated_client.post, class:TestDeleteWorkspace, method:test_delete_without_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, call:response.json, method:test_delete_with_instances_force(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, class:TestSyncWorkspace, method:test_sync_success(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, method:test_sync_branch_deleted(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, func:_get_user_id_from_client(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id_from_client, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh | dep: asyncio, uuid, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.services.shared.workspace_manager, src.auth.session, src.config, fastapi, sqlalchemy
## arch ## arch
Pytest-based integration testing with FastAPI TestClient, SQLAlchemy async sessions, real PostgreSQL database fixtures, and selective mocking; follows pattern of endpoint-per-test-file with authenticated client fixtures and seeded database state. Pytest-based integration testing using real external dependencies (PostgreSQL, git repos), FastAPI TestClient with authenticated fixtures, mocked database fixtures for isolation, and security validation patterns (cookie/ OIDC/ SSE auth).
## tags ## tags
test, call:, call:db, call:authenticated, user, create, call:response.json, call:create test, call:, call:db, call:authenticated, user, create, call:response.json, call:create
## symbols ## symbols
@@ -1,34 +0,0 @@
"""Integration tests for tool container home directory behavior.
These tests exercise container lifecycle behavior and require Docker and a
running PostgreSQL database. They are skipped when Docker is unavailable.
"""
import subprocess
import pytest
def _docker_available() -> bool:
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except Exception:
return False
@pytest.mark.integration
@pytest.mark.skipif(not _docker_available(), reason="Docker not available")
def test_docker_available_placeholder() -> None:
"""Placeholder to keep the test file valid when Docker is present.
Real lifecycle tests (container starts with HOME=/home/user, /workspace
symlink works, git mounts are writable) should be added here once the
test harness can start the API and Docker services.
"""
assert _docker_available()
+3 -9
View File
@@ -2,17 +2,14 @@
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 comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
## 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
## children ## children
- apps/api/tests/unit/.ruff_cache -
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
map: apps/api/tests/unit/.ruff_cache/.pi-map.md
## files ## files
- __init__.py - __init__.py
- test_alembic_migrations.py
- test_config.py - test_config.py
- test_config_profile_resolver.py - test_config_profile_resolver.py
- test_docker_build.py - test_docker_build.py
@@ -24,7 +21,6 @@ map: apps/api/tests/.pi-map.md
- test_git_url_parser.py - test_git_url_parser.py
- test_health_monitor.py - test_health_monitor.py
- test_home_path_expansion.py - test_home_path_expansion.py
- test_instance_service.py
- test_lifecycle_hooks.py - test_lifecycle_hooks.py
- test_manifest_compiler.py - test_manifest_compiler.py
- test_migration_metadata.py - test_migration_metadata.py
@@ -39,10 +35,8 @@ 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
## workflows ## workflows
- change unit behavior - change unit behavior
read: __init__.py, test_alembic_migrations.py, test_config.py read: __init__.py, test_config.py, test_config_profile_resolver.py
- change unit config - change unit config
read: test_config.py, test_config_profile_resolver.py read: test_config.py, test_config_profile_resolver.py
- explore unit subdirectories
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
+5 -9
View File
@@ -4,10 +4,9 @@ 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 comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
## files ## files
- __init__.py | Empty file with no functionality - __init__.py | Empty file with no functionality
- 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 | Tests configuration settings and database URL building for an application, verifying defaults, environment variable overrides, and environment-specific behavior. | 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.py | Tests configuration settings and database URL building for an application, verifying defaults, environment variable overrides, and environment-specific behavior. | 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 | Tests the config profile resolution system 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 | Tests the config profile resolution system 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_docker_build.py | Unit tests for a Docker image build service that verifies Dockerfile creation, command structure, context file handling, path traversal prevention, and error handling. | 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 Dockerfile creation, command structure, context file handling, path traversal prevention, and error handling. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build
@@ -19,9 +18,8 @@ Contains unit tests for the API application's core services, utilities, and infr
- test_git_url_parser.py | Tests for git URL parsing utilities that extract base repository URLs, validate clone URLs, and parse various git URL formats across GitHub, GitLab, and Bitbucket. | 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 | Tests for git URL parsing utilities that extract base repository URLs, validate clone URLs, and parse various git URL formats across GitHub, GitLab, and Bitbucket. | 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 deduplication, exception resilience, and start/stop lifecycle. | 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, exception resilience, and start/stop lifecycle. | 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 home directory path expansion (~ and $HOME) in container paths and manifest 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 home directory path expansion (~ and $HOME) in container paths and manifest 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, 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: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 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_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 with user configuration and home directory setup | exp: 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 | dep: pytest, src.services.build.manifest_compiler
- test_migration_metadata.py | Tests Alembic database migration files for correct 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_migration_metadata.py | Tests Alembic database migration files for correct 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 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_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, sorting, and ownership isolation. | 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, NotificationService, Notification, User, AsyncSession - test_notification_service.py | Unit tests for NotificationService covering CRUD operations, filtering, sorting, and ownership isolation. | 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, NotificationService, Notification, User, AsyncSession
@@ -30,9 +28,9 @@ Contains unit tests for the API application's core services, utilities, and infr
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands via docker exec with retry logic. | 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 via docker exec with retry logic. | 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 preparation functionality including file creation, permissions, ownership, and error handling | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.shared.ssh_keys
## arch ## arch
Comprehensive unit test suite using mocked dependencies to test business logic in isolation, covering database migrations, configuration, Docker operations, Git workflows, file services, event handling, health monitoring, and SSH/security utilities. Standard Python unittest/pytest pattern with heavy mocking of external dependencies (subprocess, docker, filesystem) to test service layer logic in isolation, organized by functional domain with one test module per service component.
## tags ## tags
test, url, call:notification, git, call:, home, merge, call:db test, url, call:notification, git, call:, call:db, merge, src
## symbols ## symbols
- TestMergeFunctions - TestMergeFunctions
- TestResolveProfile - TestResolveProfile
@@ -44,10 +42,8 @@ test, url, call:notification, git, call:, home, merge, call:db
- TestSortVolumesBySpecificity - TestSortVolumesBySpecificity
## workflows ## workflows
- change unit behavior - change unit behavior
read: __init__.py, test_alembic_migrations.py, test_config.py read: __init__.py, test_config.py, test_config_profile_resolver.py
- change unit config - change unit config
read: test_config.py, test_config_profile_resolver.py read: test_config.py, test_config_profile_resolver.py
- explore unit subdirectories
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
## dirty ## dirty
- -
@@ -1,69 +0,0 @@
"""Unit tests for Alembic migration structure/import.
Actual upgrade/downgrade round-trips require a PostgreSQL database, so these
tests verify that migrations are importable, have the expected identifiers,
and declare the expected dependencies.
"""
import importlib.util
from pathlib import Path
import pytest
@pytest.mark.unit
def test_home_directory_migration_imports_and_rewrites() -> None:
migration_path = Path(__file__).parent.parent.parent / (
"alembic/versions/2026_06_14_104415_add_tool_type_home_directory.py"
)
assert migration_path.exists()
spec = importlib.util.spec_from_file_location("home_dir_migration", migration_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "2026_06_14_104415"
assert module.down_revision == "f3d2dc90ba3a"
assert callable(module.upgrade)
assert callable(module.downgrade)
assert module.OLD_WORKSPACE == "/workspace"
assert module.NEW_WORKSPACE == "/home/user/{{WORKSPACE_NAME}}"
@pytest.mark.unit
def test_merge_migration_resolves_heads() -> None:
migration_path = Path(__file__).parent.parent.parent / (
"alembic/versions/fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py"
)
assert migration_path.exists()
spec = importlib.util.spec_from_file_location("merge_migration", migration_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "fc8f1a20cbf6"
assert "2026_06_14_104415" in module.down_revision
assert "8c6d1dbd4798" in module.down_revision
assert callable(module.upgrade)
@pytest.mark.unit
def test_remove_pi_agent_repo_mount_migration_imports() -> None:
migration_path = Path(__file__).parent.parent.parent / (
"alembic/versions/2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py"
)
assert migration_path.exists()
spec = importlib.util.spec_from_file_location(
"remove_repo_mount_migration", migration_path
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "2026_06_15_090500"
assert module.down_revision == "2026_06_14_182955"
assert callable(module.upgrade)
assert callable(module.downgrade)
@@ -1,328 +0,0 @@
"""Unit tests for the tool instance service."""
from unittest.mock import MagicMock, AsyncMock
import pytest
from src.services.tool.instance_service import (
_get_repository_mount_name,
_stack_profile_mounts_with_git_mounts,
modify_compose_file,
prepare_manifest_instance,
)
@pytest.mark.unit
class TestModifyComposeFile:
"""Tests for modify_compose_file home-directory expansion."""
def test_extra_volumes_expand_home_dir(self, tmp_path):
compose_path = tmp_path / "docker-compose.yml"
compose_path.write_text(
"services:\n app:\n image: test:latest\n volumes: []\n"
)
modify_compose_file(
str(compose_path),
extra_volumes=[
{"source": "/host/config", "target": "~/.config", "type": "bind"},
{"source": "/host/code", "target": "$HOME/code", "type": "bind"},
],
home_dir="/home/user",
)
content = compose_path.read_text()
assert "/host/config:/home/user/.config" in content
assert "/host/code:/home/user/code" in content
def test_working_directory_expands_home_dir(self, tmp_path):
compose_path = tmp_path / "docker-compose.yml"
compose_path.write_text("services:\n app:\n image: test:latest\n")
modify_compose_file(
str(compose_path),
working_directory="~/workspace",
home_dir="/home/user",
)
content = compose_path.read_text()
assert "working_dir: /home/user/workspace" in content
@pytest.mark.unit
class TestGetRepositoryMountName:
"""Tests for _get_repository_mount_name."""
def test_prefers_remote_url_name_over_user_provided_name(self):
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
assert _get_repository_mount_name(repo) == "headquarter"
def test_parses_browser_url_to_repo_name(self):
repo = MagicMock()
repo.name = "src"
repo.remote_url = "https://github.com/acme/headquarter/tree/main"
assert _get_repository_mount_name(repo) == "headquarter"
def test_uses_workspace_path_basename_when_workspace_provided(self):
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
workspace = MagicMock()
workspace.path = "/data/working-copies/uuid/headquarter"
assert _get_repository_mount_name(repo, workspace) == "headquarter"
def test_falls_back_to_repo_name_when_remote_url_missing(self):
repo = MagicMock()
repo.name = "my-cool-repo"
repo.remote_url = None
assert _get_repository_mount_name(repo) == "my-cool-repo"
def test_falls_back_to_repo_name_for_unparseable_url(self):
repo = MagicMock()
repo.name = "my-cool-repo"
repo.remote_url = ""
assert _get_repository_mount_name(repo) == "my-cool-repo"
@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts."""
def test_exact_overlap_merges_profile_files_into_git_source(
self, tmp_path
) -> None:
"""When a profile mount targets the same directory as a git mount,
the profile files should be copied into the git-mount source so the
container sees both sets of files through one bind mount."""
git_source = tmp_path / "git" / "repo-clone"
git_source.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git")
profile_source = tmp_path / "profile" / "home_user_.pi"
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
"readonly": False,
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "existing.txt").read_text() == "from git"
assert (git_source / "settings.json").read_text() == "{}"
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
"""Profile mounts targeting a child directory are copied into the
corresponding subdirectory of the git-mount source."""
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "profile" / "agent"
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("x")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/agent",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "agent" / "settings.json").read_text() == "x"
assert (git_source / "README").read_text() == "repo"
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
"""Profile mounts that do not overlap a git mount are returned as-is."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config").write_text("c")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.config",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_git_source_file_does_not_consume_profile_mount(self, tmp_path) -> None:
"""If the overlapping git-mount source is a file, the profile mount
cannot be merged and must be kept."""
git_source = tmp_path / "file.txt"
git_source.write_text("file")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
]
git_mount_volumes = [
{
"source": str(git_source),
"target": "/home/user/.pi/file.txt",
"type": "bind",
}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
"""A profile mount that supplies a single file is copied into the
git-mount source directory."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "settings.json"
profile_source.write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "settings.json").read_text() == "{}"
@pytest.mark.unit
async def test_prepare_manifest_instance_uses_workspace_path_basename():
"""WORKSPACE_NAME must be the repo-named workspace directory, not workspace.name."""
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
repo.path = "/data/repos/main"
workspace = MagicMock()
workspace.id = "workspace-uuid"
workspace.repo_id = "repo-uuid"
workspace.name = "main"
workspace.path = "/data/working-copies/workspace-uuid/headquarter"
tool_type = MagicMock()
tool_type.name = "pi-agent"
tool_type.manifest_id = "manifest-uuid"
manifest_def = MagicMock()
manifest_def.id = "manifest-uuid"
manifest_def.manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "user", "uid": 1001, "gid": 1001},
}
manifest_def.base_definition_id = None
instance = MagicMock()
instance.id = "instance-uuid"
instance.name = "pi-agent-headquarter-abc123"
instance.repository_id = "repo-uuid"
instance.tool_type_id = "tooltype-uuid"
instance.port = 0
instance.selected_config_profile_id = None
instance.workspace_id = "workspace-uuid"
session = AsyncMock()
async def session_get(model, obj_id):
if model.__name__ == "ToolType":
return tool_type
if model.__name__ == "ToolDefinitionManifest":
return manifest_def
if model.__name__ == "GitRepository":
return repo
if model.__name__ == "Workspace":
return workspace
return None
session.get.side_effect = session_get
# Patch docker images check to report the image already exists so we skip
# the actual Docker build.
import subprocess
from src.services.tool import instance_service
original_run = subprocess.run
def fake_run(cmd, **kwargs):
class Result:
returncode = 0
stdout = "image-id"
stderr = ""
return Result()
instance_service.subprocess.run = fake_run
try:
(
image_tag,
compose_content,
manifest,
home_dir,
) = await prepare_manifest_instance(
session=session,
instance=instance,
instance_dir="/tmp/instance",
repo_path="/data/working-copies/workspace-uuid/headquarter",
env_vars={},
extra_volumes=[],
working_directory=None,
)
finally:
instance_service.subprocess.run = original_run
assert "WORKSPACE_NAME: headquarter" in compose_content
assert "/data/working-copies/workspace-uuid/headquarter:/home/user/headquarter" in compose_content
+4 -327
View File
@@ -2,12 +2,7 @@
import pytest import pytest
from src.services.build.manifest_compiler import ( from src.services.build.manifest_compiler import compile_dockerfile
compile_compose,
compile_dockerfile,
compile_entrypoint,
get_manifest_home_dir,
)
@pytest.mark.unit @pytest.mark.unit
@@ -24,16 +19,9 @@ def test_compile_dockerfile_creates_config_dirs_for_user() -> None:
assert "groupadd -g 1000 dev" in dockerfile assert "groupadd -g 1000 dev" in dockerfile
assert "useradd -u 1000 -g 1000 -m -s /bin/bash dev" in dockerfile assert "useradd -u 1000 -g 1000 -m -s /bin/bash dev" in dockerfile
assert "mkdir -p /home/dev && chown -R dev:dev /home/dev" in dockerfile assert "mkdir -p /home/dev && chown -R dev:dev /home/dev" in dockerfile
assert ( assert "mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile
"mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile assert "mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share" in dockerfile
) assert "mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
assert (
"mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share"
in dockerfile
)
assert (
"mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
)
@pytest.mark.unit @pytest.mark.unit
@@ -48,314 +36,3 @@ def test_compile_dockerfile_no_user_does_not_create_home() -> None:
assert "useradd" not in dockerfile assert "useradd" not in dockerfile
assert "/home/" not in dockerfile assert "/home/" not in dockerfile
@pytest.mark.unit
class TestGetManifestHomeDir:
"""Tests for get_manifest_home_dir precedence."""
def test_home_directory_in_manifest_wins(self) -> None:
manifest = {
"home_directory": "/home/custom",
"user": {"name": "dev"},
}
assert get_manifest_home_dir(manifest) == "/home/custom"
def test_user_name_derives_home(self) -> None:
manifest = {"user": {"name": "dev", "uid": 1000, "gid": 1000}}
assert get_manifest_home_dir(manifest) == "/home/dev"
def test_root_fallback(self) -> None:
manifest = {"base_image": "ubuntu:24.04"}
assert get_manifest_home_dir(manifest) == "/root"
def test_empty_home_directory_falls_back(self) -> None:
manifest = {"home_directory": "", "user": {"name": "dev"}}
assert get_manifest_home_dir(manifest) == "/home/dev"
@pytest.mark.unit
class TestCompileDockerfileHomeDirectory:
"""Tests that compile_dockerfile honors manifest.home_directory."""
def test_env_home_and_workdir_use_home_directory(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
assert "ENV HOME=/home/custom" in dockerfile
assert "WORKDIR /home/custom" in dockerfile
def test_workspace_symlink_created(self) -> None:
"""When workspace name is known at build time, create /workspace symlink."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"workspace_name": "my-app",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
assert "mkdir -p /home/custom" in dockerfile
assert "ln -sfn /home/custom/my-app /workspace" in dockerfile
def test_runtime_workspace_not_baked_into_image(self) -> None:
"""When workspace name is a runtime placeholder, do not create literal
{{WORKSPACE_NAME}} directories or symlinks in the image."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [{"source_type": "repo", "target": "~/{{WORKSPACE_NAME}}"}],
}
dockerfile = compile_dockerfile(manifest)
assert "{{WORKSPACE_NAME}}" not in dockerfile
assert "ln -sfn" not in dockerfile
def test_runtime_working_dir_overrides_home_workdir(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"runtime": {"working_dir": "/app/code"},
}
dockerfile = compile_dockerfile(manifest)
assert "WORKDIR /app/code" in dockerfile
assert "WORKDIR /home/custom" not in dockerfile
def test_working_dir_expands_tilde(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"runtime": {"working_dir": "~/code"},
}
dockerfile = compile_dockerfile(manifest)
assert "WORKDIR /home/custom/code" in dockerfile
@pytest.mark.unit
class TestCompileComposeHomeDirectory:
"""Tests that compile_compose uses home_directory for volumes and working_dir."""
def test_default_repo_mount_synthesized(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "/host/repos/my-app:/home/custom/my-app" in compose
def test_explicit_repo_mount_preserved(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [
{"source_type": "repo", "target": "/opt/code", "readonly": True}
],
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "/host/repos/my-app:/opt/code:ro" in compose
assert "/home/custom/my-app" not in compose
def test_workspace_name_substituted_in_mount_target(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [{"source_type": "repo", "target": "~/{{WORKSPACE_NAME}}"}],
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "/host/repos/my-app:/home/custom/my-app" in compose
assert "WORKSPACE_NAME: my-app" in compose
def test_working_dir_expands_home(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"runtime": {"working_dir": "$HOME/code"},
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "working_dir: /home/custom/code" in compose
@pytest.mark.unit
def test_compile_dockerfile_uses_user_npm_prefix() -> None:
"""npm global packages must be installed into a user-writable prefix."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"packages": {"npm_global": ["@scope/pkg"]},
}
dockerfile = compile_dockerfile(manifest)
assert "npm install -g --prefix /home/custom/.npm-global" in dockerfile
assert "/home/custom/.npm-global/bin:$PATH" in dockerfile
assert "ENV PATH=/home/custom/.npm-global/bin:$PATH" in dockerfile
@pytest.mark.unit
def test_compile_dockerfile_starts_as_root_and_drops_privileges() -> None:
"""The Dockerfile must not set USER so the entrypoint starts as root.
The entrypoint itself drops privileges to the container user before
exec-ing the real command.
"""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
assert "USER dev" not in dockerfile
assert 'exec runuser -u dev -- /bin/bash -il' in entrypoint
assert 'exec runuser -u dev -- "$@"' in entrypoint
@pytest.mark.unit
def test_compile_compose_runs_as_root() -> None:
"""The compose service must start as root so the entrypoint can fix /workspace."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "user: 0:0" in compose
@pytest.mark.unit
class TestCompileEntrypoint:
"""Tests for the generated permission-fixing entrypoint."""
def test_entrypoint_creates_home_and_workspace(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
entrypoint = compile_entrypoint(manifest)
assert 'mkdir -p "$HOME_DIR"' in entrypoint
assert 'mkdir -p "$WORKSPACE_TARGET"' in entrypoint
assert 'ln -sfn "$WORKSPACE_TARGET" /workspace' in entrypoint
assert 'WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"' in entrypoint
def test_entrypoint_removes_stale_placeholder_directory(self) -> None:
"""Older images baked in a literal {{WORKSPACE_NAME}} directory."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
entrypoint = compile_entrypoint(manifest)
assert 'if [ -d "${HOME_DIR}/{{WORKSPACE_NAME}}" ]; then' in entrypoint
assert 'rm -rf "${HOME_DIR}/{{WORKSPACE_NAME}}"' in entrypoint
def test_entrypoint_uses_root_then_sudo_for_workspace_symlink(self) -> None:
"""/workspace is under /, so root takes precedence; non-root falls back to sudo."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
entrypoint = compile_entrypoint(manifest)
root_idx = entrypoint.find('if [ "$(id -u)" = "0" ]; then')
sudo_idx = entrypoint.find('elif [ -n "$SUDO" ]; then')
assert root_idx != -1
assert sudo_idx != -1
assert root_idx < sudo_idx
assert 'sudo ln -sfn "$WORKSPACE_TARGET" /workspace' in entrypoint
def test_entrypoint_fixes_mount_owners(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [
{"target": "~/.config", "readonly": False},
{"target": "/opt/readonly", "readonly": True},
],
}
entrypoint = compile_entrypoint(manifest)
assert 'fix_owner "/home/custom/.config"' in entrypoint
assert 'fix_owner "/opt/readonly"' not in entrypoint
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web dir: apps/web
## role ## role
Frontend web application providing the browser-based user interface for the project, built with React/Vite and served via nginx. Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
## parent ## parent
index: apps/.pi-map.index.md index: apps/.pi-map.index.md
map: apps/.pi-map.md map: apps/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web
index: apps/web/.pi-map.index.md index: apps/web/.pi-map.index.md
## role ## role
Frontend web application providing the browser-based user interface for the project, built with React/Vite and served via nginx. Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
## files ## files
- .env.example | Template file defining example environment variables for frontend API and application URL configuration - .env.example | Template file defining example environment variables for frontend API and application URL configuration
- .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ECMAScript module support | dep: @typescript-eslint/parser, @typescript-eslint/eslint-plugin, eslint - .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ECMAScript module support | dep: @typescript-eslint/parser, @typescript-eslint/eslint-plugin, eslint
@@ -16,7 +16,7 @@ Frontend web application providing the browser-based user interface for the proj
- tsconfig.json | TypeScript configuration file for a React project using Vite with modern ES2020 target and bundler module resolution | dep: typescript, react, vite - tsconfig.json | TypeScript configuration file for a React project using Vite with modern ES2020 target and bundler module resolution | dep: typescript, react, vite
- vite.config.ts | Configures Vite build tool for a React project with custom dev server port and Vitest test settings. | dep: vite, @vitejs/plugin-react - vite.config.ts | Configures Vite build tool for a React project with custom dev server port and Vitest test settings. | dep: vite, @vitejs/plugin-react
## arch ## arch
Modern SPA architecture using Vite for bundling, TypeScript for type safety, React for UI components, with containerized nginx deployment and environment-driven configuration. Modern React SPA built with Vite and TypeScript, using nginx for production serving with client-side routing, multi-stage Docker deployment, and Vitest for testing.
## tags ## tags
react, eslint, vite, typescript, dom, application, nginx, web react, eslint, vite, typescript, dom, application, nginx, web
## symbols ## symbols
-512
View File
@@ -897,24 +897,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -932,24 +914,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -967,24 +931,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -6108,420 +6054,6 @@
} }
} }
}, },
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@vitest/mocker": { "node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
@@ -6549,50 +6081,6 @@
} }
} }
}, },
"node_modules/vitest/node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/vitest/node_modules/picomatch": { "node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src dir: apps/web/src
## role ## role
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend. Frontend web application entry point and core infrastructure for a React-based collaborative development platform.
## parent ## parent
index: apps/web/.pi-map.index.md index: apps/web/.pi-map.index.md
map: apps/web/.pi-map.md map: apps/web/.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: apps/web/src
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
## role ## role
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend. Frontend web application entry point and core infrastructure for a React-based collaborative development platform.
## files ## files
- main.tsx | Bootstraps a React application with routing, authentication, and session management providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom - main.tsx | Bootstraps a React application with routing, authentication, and session management providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom
- router.tsx | Defines the React Router configuration for a web application with protected routes, nested layouts, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage - router.tsx | Defines the React Router configuration for a web application with protected routes, nested layouts, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage
- types.ts | Defines TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos - types.ts | Defines TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
## arch ## arch
Implements a modular React SPA architecture using React Router v6 with nested route layouts, protected route guards via authentication/session providers, and centralized TypeScript domain modeling for cross-cutting concerns. Modular React SPA using React Router v6 with nested route layouts, protected route guards via authentication context, and centralized TypeScript domain models for session/workspace/project entities.
## tags ## tags
pages, styles, css, router, react, session, dom, project pages, styles, css, router, react, session, dom, project
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/components dir: apps/web/src/components
## role ## role
Provides reusable, accessible UI primitives and layout components for a React web application, including shell layout, data states, icons, code editing, routing guards, and toast notifications. Provides reusable, accessible UI components and utilities for rendering the application shell, data states, icons, code display, notifications, and route protection in a React web application.
## parent ## parent
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md map: apps/web/src/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web/src/components
index: apps/web/src/components/.pi-map.index.md index: apps/web/src/components/.pi-map.index.md
## role ## role
Provides reusable, accessible UI primitives and layout components for a React web application, including shell layout, data states, icons, code editing, routing guards, and toast notifications. Provides reusable, accessible UI components and utilities for rendering the application shell, data states, icons, code display, notifications, and route protection in a React web application.
## files ## files
- app-shell.tsx | Renders the main application shell layout with navigation, header, session management, and mobile-responsive behavior for a React Router-based SPA. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons - app-shell.tsx | Renders the main application shell layout with navigation, header, session management, and mobile-responsive behavior for a React Router-based SPA. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
- code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language - code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
@@ -17,7 +17,7 @@ Provides reusable, accessible UI primitives and layout components for a React we
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events - toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast state module, InstanceEventPayload type - toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast state module, InstanceEventPayload type
## arch ## arch
Component-based React architecture with functional components, composition patterns, and separation of concerns between presentation (UI rendering), logic (rules/hooks), and testing; integrates third-party libraries (react-simple-code-editor, Phosphor icons) and React Router for SPA navigation. Component-based React architecture with functional components, composition patterns, separation of concerns (presentation vs logic), and test coverage for critical utilities; integrates third-party libraries (Phosphor icons, react-simple-code-editor) and React Router for SPA navigation.
## tags ## tags
toast, icon, react, state, code, loading, event, editor toast, icon, react, state, code, loading, event, editor
## symbols ## symbols
+9 -20
View File
@@ -46,13 +46,9 @@ const SessionItem = ({ session }: { session: Session }) => {
? `/instances/${session.id}/terminal` ? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`; : `/projects/${session.project_id}`;
const contextName = session.workspace_name || session.repository_name; const tooltipParts = [session.display_name, session.project_name];
const tooltipParts = [ if (session.workspace_name) tooltipParts.push(session.workspace_name);
session.display_name, else if (session.repository_name) tooltipParts.push(session.repository_name);
session.tool_type_name,
session.project_name,
];
if (contextName) tooltipParts.push(contextName);
tooltipParts.push(`(${session.status})`); tooltipParts.push(`(${session.status})`);
return ( return (
@@ -61,20 +57,13 @@ const SessionItem = ({ session }: { session: Session }) => {
target={`session-${session.id}`} target={`session-${session.id}`}
rel="noreferrer" rel="noreferrer"
className="nav-item session-item" className="nav-item session-item"
title={tooltipParts.join(" · ")} title={tooltipParts.join(" ")}
> >
<span className="session-icon-wrap"> <span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" /> <Icon name={session.tool_icon as IconName} size="sm" />
<span className={`session-status ${isRunning ? "running" : ""}`} /> <span className="session-name">
</span> {session.display_name}
<span className="session-meta"> <span className="session-tool">{session.tool_type_name}</span>
{contextName && (
<span className="session-workspace">{contextName}</span>
)}
<span className="session-display-name">{session.display_name}</span>
<span className="session-context">
{session.tool_type_name} · {session.project_name}
</span>
</span> </span>
</a> </a>
); );
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features dir: apps/web/src/components/features
## role ## role
Contains reusable React components that implement specific product features and user-facing functionality across the web application. Contains reusable React components that implement specific user-facing features and functionality across the web application.
## parent ## parent
index: apps/web/src/components/.pi-map.index.md index: apps/web/src/components/.pi-map.index.md
map: apps/web/src/components/.pi-map.md map: apps/web/src/components/.pi-map.md
@@ -19,9 +19,6 @@ map: apps/web/src/components/.pi-map.md
- apps/web/src/components/features/notification - apps/web/src/components/features/notification
index: apps/web/src/components/features/notification/.pi-map.index.md index: apps/web/src/components/features/notification/.pi-map.index.md
map: apps/web/src/components/features/notification/.pi-map.md map: apps/web/src/components/features/notification/.pi-map.md
- apps/web/src/components/features/profile
index: apps/web/src/components/features/profile/.pi-map.index.md
map: apps/web/src/components/features/profile/.pi-map.md
- apps/web/src/components/features/project - apps/web/src/components/features/project
index: apps/web/src/components/features/project/.pi-map.index.md index: apps/web/src/components/features/project/.pi-map.index.md
map: apps/web/src/components/features/project/.pi-map.md map: apps/web/src/components/features/project/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
index: apps/web/src/components/features/.pi-map.index.md index: apps/web/src/components/features/.pi-map.index.md
## role ## role
Contains reusable React components that implement specific product features and user-facing functionality across the web application. Contains reusable React components that implement specific user-facing features and functionality across the web application.
## files ## files
## arch ## arch
Feature-based component organization with domain-specific groupings, likely combining presentational and container patterns with hooks for state management, following a modular architecture where each feature encapsulates its own UI, logic, and data fetching concerns. Feature-based component organization with domain-specific UI building blocks following React composition patterns, likely co-located with related hooks, utilities, or sub-components for each feature area.
## tags ## tags
- -
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/config-profiles dir: apps/web/src/components/features/config-profiles
## role ## role
Provides UI components for managing Docker configuration profiles including creation, editing, listing, and mobile-responsive views. Provides a complete UI subsystem for managing configuration profiles with desktop and mobile views, supporting CRUD operations, profile relationships, and advanced configuration options.
## parent ## parent
index: apps/web/src/components/features/.pi-map.index.md index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md map: apps/web/src/components/features/.pi-map.md
@@ -4,15 +4,15 @@ dir: apps/web/src/components/features/config-profiles
index: apps/web/src/components/features/config-profiles/.pi-map.index.md index: apps/web/src/components/features/config-profiles/.pi-map.index.md
## role ## role
Provides UI components for managing Docker configuration profiles including creation, editing, listing, and mobile-responsive views. Provides a complete UI subsystem for managing configuration profiles with desktop and mobile views, supporting CRUD operations, profile relationships, and advanced configuration options.
## files ## files
- ConfigProfileEditorPanel.tsx | React component that renders a form-based editor panel for creating and editing configuration profiles with support for includes, environment variables, runtime hints, files, and mounts. | exp: ConfigProfileEditorPanel | dep: ../../icon, ../git/git-mount-editor, ../../../api/config-profiles, ../../../types, ../../../api/tool-types, React, Icon, GitMountEditor, ConfigProfile, CreateConfigProfileRequest, ResolvedProfile, ProjectWithRepos, ToolType - ConfigProfileEditorPanel.tsx | React component that renders a form-based editor panel for creating and editing configuration profiles with support for includes, environment variables, runtime hints, files, and mounts. | exp: ConfigProfileEditorPanel | dep: ../../icon, ../git/git-mount-editor, ../../../api/config-profiles, ../../../types, ../../../api/tool-types, React, Icon, GitMountEditor, ConfigProfile, CreateConfigProfileRequest, ResolvedProfile, ProjectWithRepos, ToolType
- ConfigProfileListSidebar.tsx | Renders a sidebar component for listing, selecting, creating, and deleting configuration profiles with visual indicators for default status, scope, and includes. | exp: ConfigProfileListSidebar | dep: ../../icon, ../../../api/config-profiles, Icon, ConfigProfile - ConfigProfileListSidebar.tsx | Renders a sidebar component for listing, selecting, creating, and deleting configuration profiles with visual indicators for default status, scope, and includes. | exp: ConfigProfileListSidebar | dep: ../../icon, ../../../api/config-profiles, Icon, ConfigProfile
- ConfigProfilesMobileView.tsx | Renders a mobile-responsive view for managing configuration profiles with list, detail, edit, and preview states. | exp: ConfigProfilesMobileView | dep: react, ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../git/git-mount-editor, ../../icon, ../../../api/config-profiles, ../../../types, ../../../api/tool-types, mobile-list-view, mobile-detail-view, mobile-edit-view, mobile-fab, git-mount-editor, icon, config-profiles API types, types - ConfigProfilesMobileView.tsx | Renders a mobile-responsive CRUD interface for managing configuration profiles with list, detail, and edit views. | exp: ConfigProfilesMobileView | dep: ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../../icon, ../../../api/config-profiles, MobileListView, MobileDetailView, MobileEditView, MobileFAB, Icon, ConfigProfile, CreateConfigProfileRequest
## arch ## arch
Feature-based component composition with state-driven panels (list/detail/edit/preview), form-based editing, and responsive layout adaptation for mobile/desktop. Compound component architecture with panel/sidebar split for desktop, dedicated mobile view with state-driven routing, shared state management across list/editor views, and form-based configuration with nested array/object handling for includes, env vars, files, and mounts.
## tags ## tags
mobile, config, view, profiles, editor, profile, list, icon config, mobile, profiles, profile, view, editor, icon, list
## symbols ## symbols
- ConfigProfileEditorPanel - ConfigProfileEditorPanel
- ConfigProfileListSidebar - ConfigProfileListSidebar
@@ -197,7 +197,7 @@ export const ConfigProfileEditorPanel = ({
> >
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span> <span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span> <span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
<span className="badge">{getScopeLabel(profile)}</span> <span style={{ fontSize: "0.75rem", padding: "0.125rem 0.375rem", background: "var(--badge-bg, #f3f4f6)", color: "var(--muted)", borderRadius: "0.25rem", textTransform: "uppercase", letterSpacing: "0.025em" }}>{getScopeLabel(profile)}</span>
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button> <button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
</div> </div>
); );
@@ -77,19 +77,30 @@ export const ConfigProfileListSidebar = ({
> >
{profile.name} {profile.name}
{profile.is_default && ( {profile.is_default && (
<span className="badge badge-secondary" style={{ marginLeft: "0.5rem" }}> <span
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.8,
textTransform: "uppercase",
letterSpacing: "0.025em",
}}
>
default default
</span> </span>
)} )}
{profile.includes?.length > 0 && ( {profile.includes?.length > 0 && (
<span <span
className="badge"
style={{ style={{
fontSize: "0.7rem",
marginLeft: "0.5rem", marginLeft: "0.5rem",
opacity: 0.7,
background: background:
selectedProfileId === profile.id selectedProfileId === profile.id
? "rgba(255,255,255,0.2)" ? "rgba(255,255,255,0.2)"
: undefined, : "var(--badge-bg, #f3f4f6)",
padding: "0.0625rem 0.375rem",
borderRadius: "0.25rem",
}} }}
> >
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""} {profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/mobile dir: apps/web/src/components/features/mobile
## role ## role
Provides a complete set of mobile-optimized UI components for CRUD operations, navigation, and terminal interfaces in a responsive web application. Provides mobile-optimized UI components for core application features including navigation, data views, forms, modals, and specialized terminal interfaces.
## parent ## parent
index: apps/web/src/components/features/.pi-map.index.md index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md map: apps/web/src/components/features/.pi-map.md
@@ -4,22 +4,22 @@ dir: apps/web/src/components/features/mobile
index: apps/web/src/components/features/mobile/.pi-map.index.md index: apps/web/src/components/features/mobile/.pi-map.index.md
## role ## role
Provides a complete set of mobile-optimized UI components for CRUD operations, navigation, and terminal interfaces in a responsive web application. Provides mobile-optimized UI components for core application features including navigation, data views, forms, modals, and specialized terminal interfaces.
## files ## files
- mobile-action-sheet.tsx | Renders a mobile-optimized action sheet modal with title, configurable action buttons, and cancel option. | exp: MobileActionSheetItem, func:MobileActionSheet({ isOpen, onClose, title, actions, }: MobileActionSheetProps), call:useRef, call:useEffect, call:onClose, call:document.addEventListener, call:document.removeEventListener, call:e.stopPropagation, call:actions.map, call:action.onClick | dep: react, ../../icon, icon - mobile-action-sheet.tsx | Renders a mobile-optimized action sheet modal with title, configurable action buttons, and cancel option. | exp: MobileActionSheetItem, func:MobileActionSheet({ isOpen, onClose, title, actions, }: MobileActionSheetProps), call:useRef, call:useEffect, call:onClose, call:document.addEventListener, call:document.removeEventListener, call:e.stopPropagation, call:actions.map, call:action.onClick | dep: react, ../../icon, icon
- mobile-detail-view.tsx | A React component that renders a mobile-optimized detail view with a header, back/edit/delete actions, and a configurable list of typed fields. | exp: MobileDetailView | dep: ../../icon, React - mobile-detail-view.tsx | A React component that renders a mobile-optimized detail view with a header (back button, title, edit/delete actions) and a field list supporting multiple value types (text, code, JSON, boolean). | exp: MobileDetailView | dep: ../../icon, react
- mobile-edit-view.tsx | A React component that renders a mobile-optimized form for editing records with configurable field types and save/cancel/delete actions. | exp: MobileEditView | dep: react - mobile-edit-view.tsx | A React component that renders a mobile-optimized form for editing data with configurable field types and save/cancel actions. | exp: MobileEditView | dep: react
- mobile-fab.tsx | Renders a floating action button component for mobile with an add icon and configurable click handler and label. | exp: MobileFAB | dep: ../../icon, react, icon - mobile-fab.tsx | Renders a floating action button component for mobile with an add icon and configurable click handler and label. | exp: MobileFAB | dep: ../../icon, react, icon
- mobile-list-view.tsx | A reusable React component that renders a mobile-optimized list view with optional search, custom item rendering, and action buttons (duplicate/delete). | exp: MobileListView | dep: react, ../../icon, ../../../utils/icons, icon - mobile-list-view.tsx | Renders a mobile-optimized list view with optional search, empty state, and customizable item rendering | exp: MobileListView | dep: react, ../../icon, ../../../utils/icons, icon
- mobile-nav.tsx | Renders a mobile navigation bar with grouped items that trigger bottom sheets and standard links with active state highlighting | exp: MobileNav | dep: react, react-router-dom, ../../icon, ../tool/tools-bottom-sheet, ./spaces-bottom-sheet, ../../../utils/icons - mobile-nav.tsx | Renders a mobile navigation bar with grouped items that open bottom sheets and standard items that use React Router links, including active state indicators and session count badges. | exp: MobileNav | dep: react, react-router-dom, ../../icon, ../tool/tools-bottom-sheet, ./spaces-bottom-sheet, ../../../utils/icons
- mobile-page-header.tsx | Renders a mobile-only page header with optional back navigation and custom actions. | exp: func:MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps), call:useNavigate, call:useMobileViewport, call:navigate | dep: react-router-dom, ../../../hooks/use-mobile-viewport, ../../icon, use-mobile-viewport hook, Icon component - mobile-page-header.tsx | Renders a mobile-only page header with optional back navigation and custom actions. | exp: func:MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps), call:useNavigate, call:useMobileViewport, call:navigate | dep: react-router-dom, ../../../hooks/use-mobile-viewport, ../../icon, use-mobile-viewport hook, Icon component
- mobile-terminal-header.tsx | Renders a mobile-responsive header for a terminal interface with navigation, title, connection status, font size controls, and close actions. | exp: MobileTerminalHeader | dep: react, ../../icon, icon - mobile-terminal-header.tsx | Renders a mobile-responsive header for a terminal interface with navigation, title, connection status, font size controls, and close actions. | exp: MobileTerminalHeader | dep: react, ../../icon, icon
- mobile-terminal-wrapper.tsx | Wraps a terminal component with mobile-specific UI including auto-hiding header, virtual keyboard handling, and special keys interface. | exp: MobileTerminalWrapper | dep: react, ../terminal/terminal, ./mobile-terminal-header, ../terminal/special-keys-strip, ../terminal/special-keys-panel, ../../../hooks/use-mobile-viewport, ../../../hooks/use-virtual-keyboard, ../../../hooks/use-auto-hide, ../../../hooks/use-special-keys - mobile-terminal-wrapper.tsx | Wraps a terminal component with mobile-specific UI including auto-hiding header, virtual keyboard handling, and special keys interface. | exp: MobileTerminalWrapper | dep: react, ../terminal/terminal, ./mobile-terminal-header, ../terminal/special-keys-strip, ../terminal/special-keys-panel, ../../../hooks/use-mobile-viewport, ../../../hooks/use-virtual-keyboard, ../../../hooks/use-auto-hide, ../../../hooks/use-special-keys
- spaces-bottom-sheet.tsx | Renders a mobile bottom sheet navigation menu for switching between "Projects" and "Workspaces" spaces with active state highlighting. | exp: SpacesBottomSheet | dep: react-router-dom, ../../icon, icon - spaces-bottom-sheet.tsx | Renders a mobile bottom sheet navigation menu for switching between "Projects" and "Workspaces" spaces with active state highlighting. | exp: SpacesBottomSheet | dep: react-router-dom, ../../icon, icon
## arch ## arch
Feature-based component architecture using compound mobile patterns (bottom sheets, action sheets, FABs) with typed configurable props, consistent mobile-first design system, and specialized terminal wrapper with native-like behaviors (auto-hiding headers, virtual keyboard handling). Compositional React component library with feature-specific mobile adaptations, using bottom sheets/action sheets for mobile-native UX patterns, and wrapper components that inject mobile-specific behaviors (auto-hiding headers, virtual keyboard handling) into existing features.
## tags ## tags
mobile, terminal, sheet, react, icon, view, header, action mobile, terminal, sheet, react, icon, view, header, renders
## symbols ## symbols
- MobileActionSheet - MobileActionSheet
- MobilePageHeader - MobilePageHeader
@@ -13,7 +13,6 @@ interface MobileDetailViewProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onBack: () => void; onBack: () => void;
children?: React.ReactNode;
} }
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
@@ -23,7 +22,6 @@ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
onEdit, onEdit,
onDelete, onDelete,
onBack, onBack,
children,
}) => { }) => {
const renderValue = (field: Field) => { const renderValue = (field: Field) => {
if (field.value === null || field.value === undefined) { if (field.value === null || field.value === undefined) {
@@ -92,7 +90,6 @@ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
</div> </div>
))} ))}
</div> </div>
{children}
</div> </div>
); );
}; };
@@ -16,8 +16,6 @@ interface MobileEditViewProps {
fields?: FormField[]; fields?: FormField[];
onSave: (data: Record<string, string | number | boolean>) => void; onSave: (data: Record<string, string | number | boolean>) => void;
onCancel: () => void; onCancel: () => void;
onDelete?: () => void;
deleteLabel?: string;
isSaving?: boolean; isSaving?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
@@ -27,8 +25,6 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
fields, fields,
onSave, onSave,
onCancel, onCancel,
onDelete,
deleteLabel = "Delete",
isSaving = false, isSaving = false,
children, children,
}) => { }) => {
@@ -63,7 +59,14 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
Cancel Cancel
</button> </button>
<h1 className="mobile-edit-title">{title}</h1> <h1 className="mobile-edit-title">{title}</h1>
<div style={{ width: "4rem" }} /> <button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
</header> </header>
<form className="mobile-edit-form" onSubmit={handleSubmit}> <form className="mobile-edit-form" onSubmit={handleSubmit}>
@@ -153,27 +156,6 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
</div> </div>
))} ))}
</form> </form>
<div className="mobile-edit-actions">
<button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
{onDelete && (
<button
className="mobile-edit-delete"
onClick={onDelete}
type="button"
disabled={isSaving}
>
{deleteLabel}
</button>
)}
</div>
</div> </div>
); );
}; };
@@ -24,8 +24,6 @@ interface MobileListViewProps {
export const MobileListView: React.FC<MobileListViewProps> = ({ export const MobileListView: React.FC<MobileListViewProps> = ({
items, items,
onItemClick, onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found", emptyMessage = "No items found",
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
onSearch, onSearch,
@@ -75,38 +73,11 @@ export const MobileListView: React.FC<MobileListViewProps> = ({
)} )}
</div> </div>
)} )}
<div className="mobile-list-item-actions"> <div
{onItemDuplicate && ( className="mobile-list-item-actions"
<button style={{ transform: "rotate(180deg)" }}
type="button" >
className="mobile-list-item-action mobile-list-item-action-duplicate" <Icon name="arrow-left" size="sm" />
onClick={(e) => {
e.stopPropagation();
onItemDuplicate(item.id);
}}
aria-label="Duplicate"
>
<Icon name="copy" size="sm" />
</button>
)}
{onItemDelete && (
<button
type="button"
className="mobile-list-item-action mobile-list-item-action-delete"
onClick={(e) => {
e.stopPropagation();
onItemDelete(item.id);
}}
aria-label="Delete"
>
<Icon name="delete" size="sm" />
</button>
)}
{!onItemDelete && !onItemDuplicate && (
<span style={{ transform: "rotate(180deg)" }}>
<Icon name="arrow-left" size="sm" />
</span>
)}
</div> </div>
</button> </button>
))} ))}
@@ -27,17 +27,10 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
const { isOpen: isKeyboardOpen, height: keyboardHeight } = const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard(); useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false); const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>( const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
null,
);
const [terminalRef, setTerminalRef] = useState<{ const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void; sendData: (data: string) => void;
connectionStatus: connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
focusInput: () => void; focusInput: () => void;
changeFontSize: (delta: number) => void; changeFontSize: (delta: number) => void;
} | null>(null); } | null>(null);
@@ -49,33 +42,17 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
}, [headerAutoHide]); }, [headerAutoHide]);
const handleTerminalReady = useCallback( const handleTerminalReady = useCallback(
( (sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
_sessionId: string | undefined, setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
sendData: (data: string) => void,
connectionStatus:
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalRef({
sendData,
connectionStatus,
focusInput,
changeFontSize,
});
}, },
[], []
); );
const handleSendKey = useCallback( const handleSendKey = useCallback(
(data: string) => { (data: string) => {
terminalRef?.sendData(data); terminalRef?.sendData(data);
}, },
[terminalRef], [terminalRef]
); );
if (!isMobile) { if (!isMobile) {
@@ -1,20 +0,0 @@
# apps/web/src/components/features/profile (index)
dir: apps/web/src/components/features/profile
## role
Provides a mobile-specific UI for users to view and edit their profile information.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
## children
-
## files
- ProfileMobileView.tsx
## links
index: apps/web/src/components/features/profile/.pi-map.index.md
map: apps/web/src/components/features/profile/.pi-map.md
## workflows
- change profile behavior
read: ProfileMobileView.tsx
## dirty
-
@@ -1,20 +0,0 @@
# apps/web/src/components/features/profile
dir: apps/web/src/components/features/profile
index: apps/web/src/components/features/profile/.pi-map.index.md
## role
Provides a mobile-specific UI for users to view and edit their profile information.
## files
- ProfileMobileView.tsx | Renders a mobile-optimized profile editing form with avatar upload, name/email fields, and save/cancel functionality. | exp: ProfileMobileView | dep: react-router-dom, ../mobile/mobile-edit-view, ../../icon, ../../../api/profile
## arch
Single feature-focused component with form handling and file upload, likely using controlled inputs and local state management.
## tags
mobile, profile, view, renders, optimized, editing, form, avatar
## symbols
- ProfileMobileView
## workflows
- change profile behavior
read: ProfileMobileView.tsx
## dirty
-
@@ -1,119 +0,0 @@
import { useNavigate } from "react-router-dom";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { Icon } from "../../icon";
import type { UserProfile } from "../../../api/profile";
interface ProfileMobileViewProps {
profile: UserProfile;
name: string;
email: string;
error: string | null;
isSaving: boolean;
avatarUrl: string | null;
fileInputRef: React.RefObject<HTMLInputElement>;
onNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onAvatarButtonClick: () => void;
onAvatarChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSave: () => void;
}
export const ProfileMobileView = ({
profile,
name,
email,
error,
isSaving,
avatarUrl,
fileInputRef,
onNameChange,
onEmailChange,
onAvatarButtonClick,
onAvatarChange,
onSave,
}: ProfileMobileViewProps) => {
const navigate = useNavigate();
return (
<MobileEditView
title="Profile"
onCancel={() => navigate(-1)}
onSave={onSave}
isSaving={isSaving}
>
<div className="profile-mobile-avatar-section">
<div className="profile-mobile-avatar">
{avatarUrl ? (
<img alt="Avatar" className="profile-mobile-avatar-image" src={avatarUrl} />
) : (
<div className="profile-mobile-avatar-placeholder">
{profile.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<button
className="secondary-button"
disabled={isSaving}
onClick={onAvatarButtonClick}
type="button"
>
{isSaving ? (
<>
<Icon name="loading" size="sm" />
Uploading...
</>
) : (
<>
<Icon name="edit" size="sm" />
Change Avatar
</>
)}
</button>
<input
accept="image/png,image/jpeg"
onChange={onAvatarChange}
ref={fileInputRef}
style={{ display: "none" }}
type="file"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="profile-mobile-name">
Name *
</label>
<input
className="mobile-form-input"
disabled={isSaving}
id="profile-mobile-name"
onChange={(e) => onNameChange(e.target.value)}
placeholder="Your name"
type="text"
value={name}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="profile-mobile-email">
Email *
</label>
<input
className="mobile-form-input"
disabled={isSaving}
id="profile-mobile-email"
onChange={(e) => onEmailChange(e.target.value)}
placeholder="your.email@example.com"
type="email"
value={email}
/>
</div>
{error && (
<p className="mobile-form-error">
<Icon name="warning" size="sm" />
{error}
</p>
)}
</MobileEditView>
);
};
@@ -1,9 +1,6 @@
import React from "react"; import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal"; import { TerminalComponent, type TerminalRef } from "./terminal";
import { import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
import type { TerminalSession } from "../../../api/terminal"; import type { TerminalSession } from "../../../api/terminal";
interface Props { interface Props {
@@ -11,9 +8,7 @@ interface Props {
sessions: TerminalSession[]; sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[]; sessionInfos: TerminalSessionInfo[];
activeSessionId: string; activeSessionId: string;
terminalRefs: React.MutableRefObject< terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
Record<string, React.RefObject<TerminalRef>>
>;
isFullscreen: boolean; isFullscreen: boolean;
status: string; status: string;
error: string | null; error: string | null;
@@ -31,7 +26,6 @@ interface Props {
onHideResetConfirm: () => void; onHideResetConfirm: () => void;
onReset: () => void; onReset: () => void;
onTerminalReady: ( onTerminalReady: (
sessionId: string | undefined,
sendData: (data: string) => void, sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting", status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void, focusInput: () => void,
@@ -70,11 +64,7 @@ export const DesktopTerminalView: React.FC<Props> = ({
> >
{!isFullscreen && ( {!isFullscreen && (
<div className="terminal-page-header"> <div className="terminal-page-header">
<button <button className="secondary-button" onClick={onNavigateBack} type="button">
className="secondary-button"
onClick={onNavigateBack}
type="button"
>
Back Back
</button> </button>
<h1>Terminal</h1> <h1>Terminal</h1>
@@ -182,25 +172,21 @@ export const DesktopTerminalView: React.FC<Props> = ({
)} )}
<div className="terminal-page-content"> <div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>} {error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => ( {sessions
<div .filter((session) => session.id === activeSessionId)
key={session.id} .map((session) => (
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`} <div key={session.id} className="terminal-instance active">
style={{ <TerminalComponent
display: session.id === activeSessionId ? "flex" : "none", ref={terminalRefs.current[session.id]}
}} instanceId={instanceId}
> sessionId={session.id}
<TerminalComponent onClose={() => onClose(session.id)}
ref={terminalRefs.current[session.id]} isMobile={false}
instanceId={instanceId} showControls={!isFullscreen}
sessionId={session.id} onTerminalReady={onTerminalReady}
onClose={() => onClose(session.id)} />
isMobile={false} </div>
showControls={!isFullscreen} ))}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && ( {sessions.length === 0 && !loading && (
<div className="terminal-empty-state"> <div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p> <p>No terminal sessions. Press Alt+Shift+N to create one.</p>
@@ -1,9 +1,6 @@
import React from "react"; import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal"; import { TerminalComponent, type TerminalRef } from "./terminal";
import { import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
import { SpecialKeysStrip } from "./special-keys-strip"; import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel"; import { SpecialKeysPanel } from "./special-keys-panel";
@@ -15,9 +12,7 @@ interface Props {
sessions: TerminalSession[]; sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[]; sessionInfos: TerminalSessionInfo[];
activeSessionId: string; activeSessionId: string;
terminalRefs: React.MutableRefObject< terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
Record<string, React.RefObject<TerminalRef>>
>;
status: string; status: string;
error: string | null; error: string | null;
loading: boolean; loading: boolean;
@@ -34,7 +29,6 @@ interface Props {
onCreate: () => void; onCreate: () => void;
onRename: (id: string, name: string) => void; onRename: (id: string, name: string) => void;
onTerminalReady: ( onTerminalReady: (
sessionId: string | undefined,
sendData: (data: string) => void, sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting", status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void, focusInput: () => void,
@@ -79,53 +73,25 @@ export const MobileTerminalView: React.FC<Props> = ({
return ( return (
<section className="terminal-page mobile"> <section className="terminal-page mobile">
<div <div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`}
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-terminal-toolbar"> <div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left"> <div className="mobile-terminal-toolbar-left">
<button <button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
className="mobile-terminal-toolbtn"
onClick={onNavigateBack}
type="button"
aria-label="Back"
>
<Icon name="arrow-left" size="sm" /> <Icon name="arrow-left" size="sm" />
</button> </button>
</div> </div>
<div className="mobile-terminal-toolbar-center"> <div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title"> <span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
{activeSession?.name || "Terminal"} <span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
</span>
<span
className={`mobile-terminal-status status-dot ${status}`}
aria-label={`Connection status: ${status}`}
/>
</div> </div>
<div className="mobile-terminal-toolbar-right"> <div className="mobile-terminal-toolbar-right">
<button <button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
className="mobile-terminal-toolbtn"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span> <span style={{ fontSize: "0.75rem" }}>A-</span>
</button> </button>
<button <button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
className="mobile-terminal-toolbtn"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span> <span style={{ fontSize: "1rem" }}>A+</span>
</button> </button>
<button <button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
className="mobile-terminal-toolbtn"
onClick={onNavigateBack}
type="button"
aria-label="Exit terminal"
>
<Icon name="close" size="sm" /> <Icon name="close" size="sm" />
</button> </button>
</div> </div>
@@ -149,27 +115,23 @@ export const MobileTerminalView: React.FC<Props> = ({
onClick={onToggleHeader} onClick={onToggleHeader}
> >
{error && <div className="terminal-error-banner">{error}</div>} {error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => ( {sessions
<div .filter((session) => session.id === activeSessionId)
key={session.id} .map((session) => (
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`} <div key={session.id} className="terminal-instance active">
style={{ <TerminalComponent
display: session.id === activeSessionId ? "flex" : "none", ref={terminalRefs.current[session.id]}
}} instanceId={instanceId}
> sessionId={session.id}
<TerminalComponent onClose={() => onClose(session.id)}
ref={terminalRefs.current[session.id]} isMobile={true}
instanceId={instanceId} showControls={false}
sessionId={session.id} activeModifier={activeModifier}
onClose={() => onClose(session.id)} onModifierChange={onModifierChange}
isMobile={true} onTerminalReady={onTerminalReady}
showControls={false} />
activeModifier={activeModifier} </div>
onModifierChange={onModifierChange} ))}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && ( {sessions.length === 0 && !loading && (
<div className="terminal-empty-state"> <div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p> <p>No terminal sessions. Press Alt+Shift+N to create one.</p>
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/tool-workshop dir: apps/web/src/components/features/tool-workshop
## role ## role
Provides a complete UI for managing custom tool types (compose, dockerfile, manifest) in a workshop interface with list, edit, and mobile-responsive views. Provides a complete UI for managing custom tool types in a "Tool Workshop" interface with list, edit, and mobile-responsive views.
## parent ## parent
index: apps/web/src/components/features/.pi-map.index.md index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md map: apps/web/src/components/features/.pi-map.md
@@ -4,15 +4,15 @@ dir: apps/web/src/components/features/tool-workshop
index: apps/web/src/components/features/tool-workshop/.pi-map.index.md index: apps/web/src/components/features/tool-workshop/.pi-map.index.md
## role ## role
Provides a complete UI for managing custom tool types (compose, dockerfile, manifest) in a workshop interface with list, edit, and mobile-responsive views. Provides a complete UI for managing custom tool types in a "Tool Workshop" interface with list, edit, and mobile-responsive views.
## files ## files
- ToolTypeEditorPanel.tsx | Renders a form panel for creating or editing tool types with support for compose, dockerfile, and manifest definition types | exp: ToolTypeFormState, ToolTypeEditorPanel | dep: ../../icon, ../tool/manifest-editor, ../../../api/tool-types, ../../../api/tool-definitions, React, Icon, ManifestEditor - ToolTypeEditorPanel.tsx | Renders a form panel for creating or editing tool types with support for compose, dockerfile, and manifest definition types | exp: ToolTypeFormState, ToolTypeEditorPanel | dep: ../../icon, ../tool/manifest-editor, ../../../api/tool-types, ../../../api/tool-definitions, React, Icon, ManifestEditor
- ToolTypeListSidebar.tsx | Renders a sidebar component for listing, selecting, creating, and deleting tool types in a "Tool Workshop" interface. | exp: ToolTypeListSidebar | dep: ../../icon, ../../../api/tool-types, React, Icon component, ToolType type - ToolTypeListSidebar.tsx | Renders a sidebar component for listing, selecting, creating, and deleting tool types in a "Tool Workshop" interface. | exp: ToolTypeListSidebar | dep: ../../icon, ../../../api/tool-types, React, Icon component, ToolType type
- ToolWorkshopMobileView.tsx | Renders a mobile-responsive workshop interface for managing tool types with list, detail, and edit views. | exp: MobileView, ToolWorkshopMobileView | dep: react, ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../tool/manifest-editor, ../../icon, ../../../api/tool-types, ./ToolTypeEditorPanel, ../../../api/tool-definitions, mobile-list-view, mobile-detail-view, mobile-edit-view, mobile-fab, manifest-editor, icon, tool-types, tool-definitions - ToolWorkshopMobileView.tsx | Renders a mobile-responsive view for managing tool types with list, detail, and edit modes | exp: MobileView, ToolWorkshopMobileView | dep: ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../../../api/tool-types, ./ToolTypeEditorPanel, MobileListView, MobileDetailView, MobileEditView, MobileFAB, ToolType, ToolTypeFormState
## arch ## arch
Feature-based component composition with split-pane layout (sidebar + editor panel), responsive mobile adaptation, and CRUD operations for tool type definitions. Uses a split-pane sidebar/detail panel pattern with dedicated mobile breakpoint handling, separating list navigation from form editing concerns across three specialized view components.
## tags ## tags
tool, mobile, view, type, types, editor, list, workshop tool, mobile, type, view, types, list, panel, editor
## symbols ## symbols
- ToolTypeFormState - ToolTypeFormState
- ToolTypeEditorPanel - ToolTypeEditorPanel
@@ -1,14 +1,9 @@
import { useState } from "react";
import { MobileListView } from "../mobile/mobile-list-view"; import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view"; import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view"; import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab"; import { MobileFAB } from "../mobile/mobile-fab";
import { ManifestEditor } from "../tool/manifest-editor";
import { Icon } from "../../icon";
import type { ToolType } from "../../../api/tool-types"; import type { ToolType } from "../../../api/tool-types";
import type { ToolTypeFormState } from "./ToolTypeEditorPanel"; import type { ToolTypeFormState } from "./ToolTypeEditorPanel";
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
export type MobileView = "list" | "detail" | "edit"; export type MobileView = "list" | "detail" | "edit";
@@ -18,18 +13,13 @@ interface ToolWorkshopMobileViewProps {
mobileView: MobileView; mobileView: MobileView;
isCreating: boolean; isCreating: boolean;
toolTypeForm: ToolTypeFormState; toolTypeForm: ToolTypeFormState;
manifestData: Record<string, unknown> | null;
manifestDefinitionId: string | null;
baseDefinitions: ToolDefinitionManifest[];
toolTypeError: string | null; toolTypeError: string | null;
toolTypeDirty: boolean;
onViewChange: (view: MobileView) => void; onViewChange: (view: MobileView) => void;
onSelect: (toolType: ToolType) => void; onSelect: (toolType: ToolType) => void;
onCreate: () => void; onCreate: () => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onFormChange: (changes: Partial<ToolTypeFormState>) => void; onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onManifestChange: (manifest: Record<string, unknown> | null) => void; onSubmit: () => void;
onSubmit: (e?: React.FormEvent) => Promise<boolean>;
onCancel: () => void; onCancel: () => void;
} }
@@ -39,41 +29,15 @@ export const ToolWorkshopMobileView = ({
mobileView, mobileView,
isCreating, isCreating,
toolTypeForm, toolTypeForm,
manifestData,
manifestDefinitionId,
baseDefinitions,
toolTypeError, toolTypeError,
toolTypeDirty,
onViewChange, onViewChange,
onSelect, onSelect,
onCreate, onCreate,
onDelete, onDelete,
onFormChange, onFormChange,
onManifestChange,
onSubmit, onSubmit,
onCancel, onCancel,
}: ToolWorkshopMobileViewProps) => { }: ToolWorkshopMobileViewProps) => {
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
const ok = await onSubmit();
if (ok) {
onViewChange(isCreating ? "list" : "edit");
}
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (selectedToolType) {
void onDelete(selectedToolType.id);
onViewChange("list");
}
};
if (mobileView === "list") { if (mobileView === "list") {
return ( return (
<div className="mobile-page"> <div className="mobile-page">
@@ -85,16 +49,15 @@ export const ToolWorkshopMobileView = ({
items={toolTypes.map((t) => ({ items={toolTypes.map((t) => ({
id: t.id, id: t.id,
title: t.display_name, title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.definition_type} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`, subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))} }))}
onItemClick={(id) => { onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id); const toolType = toolTypes.find((t) => t.id === id);
if (toolType) { if (toolType) {
onSelect(toolType); onSelect(toolType);
onViewChange("edit"); onViewChange("detail");
} }
}} }}
onItemDelete={(id) => onDelete(id)}
emptyMessage="No tool types yet" emptyMessage="No tool types yet"
/> />
<MobileFAB <MobileFAB
@@ -108,70 +71,64 @@ export const ToolWorkshopMobileView = ({
} }
if (mobileView === "detail" && selectedToolType) { if (mobileView === "detail" && selectedToolType) {
const fields = [
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean" as const,
},
...(selectedToolType.requires_port
? [{ label: "Default Port", value: selectedToolType.default_port }]
: []),
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
...(selectedToolType.definition_type !== "manifest"
? [
{
label:
selectedToolType.definition_type === "compose"
? "Compose Template"
: "Dockerfile Template",
value:
selectedToolType.definition_type === "compose"
? selectedToolType.compose_template
: selectedToolType.dockerfile_template,
type: "code" as const,
},
]
: []),
];
return ( return (
<MobileDetailView <MobileDetailView
title={selectedToolType.display_name} title={selectedToolType.display_name}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type}`} subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
fields={fields} fields={[
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean",
},
{ label: "Default Port", value: selectedToolType.default_port },
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
{
label: "Compose Template",
value: selectedToolType.compose_template,
type: "code",
},
{
label: "Dockerfile Template",
value: selectedToolType.dockerfile_template,
type: "code",
},
]}
onEdit={() => { onEdit={() => {
onViewChange("edit"); onViewChange("edit");
}} }}
onDelete={handleDelete} onDelete={() => {
void onDelete(selectedToolType.id);
onViewChange("list");
}}
onBack={() => { onBack={() => {
onViewChange("list"); onViewChange("list");
}} }}
@@ -180,41 +137,18 @@ export const ToolWorkshopMobileView = ({
} }
if (mobileView === "edit") { if (mobileView === "edit") {
const templateValue =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
return ( return (
<MobileEditView <MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"} title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={onCancel} onCancel={onCancel}
onSave={handleSave} onSave={() => {
onDelete={isCreating ? undefined : handleDelete} onSubmit();
deleteLabel="Delete Tool Type" if (!toolTypeError) {
isSaving={isSaving} onViewChange("list");
}
}}
isSaving={false}
> >
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
onFormChange({
definition_type: e.target.value as
| "compose"
| "dockerfile"
| "manifest",
})
}
className="mobile-form-select"
disabled={!isCreating}
>
<option value="compose">Docker Compose</option>
<option value="dockerfile">Dockerfile</option>
<option value="manifest">Manifest (Declarative)</option>
</select>
</div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Name *</label> <label className="mobile-form-label">Name *</label>
<input <input
@@ -223,11 +157,8 @@ export const ToolWorkshopMobileView = ({
onChange={(e) => onFormChange({ name: e.target.value })} onChange={(e) => onFormChange({ name: e.target.value })}
className="mobile-form-input" className="mobile-form-input"
placeholder="e.g., my-tool" placeholder="e.g., my-tool"
disabled={!isCreating}
required
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Display Name *</label> <label className="mobile-form-label">Display Name *</label>
<input <input
@@ -236,10 +167,8 @@ export const ToolWorkshopMobileView = ({
onChange={(e) => onFormChange({ display_name: e.target.value })} onChange={(e) => onFormChange({ display_name: e.target.value })}
className="mobile-form-input" className="mobile-form-input"
placeholder="e.g., My Tool" placeholder="e.g., My Tool"
required
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Description</label> <label className="mobile-form-label">Description</label>
<textarea <textarea
@@ -250,7 +179,6 @@ export const ToolWorkshopMobileView = ({
rows={3} rows={3}
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Category</label> <label className="mobile-form-label">Category</label>
<input <input
@@ -261,179 +189,130 @@ export const ToolWorkshopMobileView = ({
placeholder="e.g., development" placeholder="e.g., development"
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Interface Type</label> <label className="mobile-form-label">Interface Type</label>
<select <select
value={toolTypeForm.interface_type} value={toolTypeForm.interface_type}
onChange={(e) => { onChange={(e) =>
const value = e.target.value as "web" | "terminal";
onFormChange({ onFormChange({
interface_type: value, interface_type: e.target.value as "web" | "terminal",
requires_port: value === "web", })
default_port: value === "web" ? toolTypeForm.default_port : "", }
});
}}
className="mobile-form-select" className="mobile-form-select"
> >
<option value="web">Web</option> <option value="web">Web</option>
<option value="terminal">Terminal</option> <option value="terminal">Terminal</option>
</select> </select>
</div> </div>
<div className="mobile-form-group">
<div className="mobile-form-group mobile-form-row"> <label className="mobile-form-label">Requires Port</label>
<label className="mobile-form-label mobile-form-checkbox-label"> <input
<input type="checkbox"
type="checkbox" checked={toolTypeForm.requires_port}
checked={toolTypeForm.requires_port} onChange={(e) => onFormChange({ requires_port: e.target.checked })}
onChange={(e) => className="mobile-form-checkbox"
onFormChange({ requires_port: e.target.checked }) />
} </div>
className="mobile-form-checkbox" <div className="mobile-form-group">
/> <label className="mobile-form-label">Default Port</label>
Requires Port <input
</label> type="text"
value={toolTypeForm.default_port}
onChange={(e) => onFormChange({ default_port: e.target.value })}
className="mobile-form-input"
placeholder="e.g., 8080"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
onFormChange({
definition_type: e.target.value as "compose" | "dockerfile",
})
}
className="mobile-form-select"
>
<option value="compose">Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => onFormChange({ startup_command: e.target.value })}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
</div> </div>
{toolTypeForm.requires_port && (
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port *</label>
<input
type="number"
value={toolTypeForm.default_port}
onChange={(e) =>
onFormChange({ default_port: e.target.value })
}
className="mobile-form-input"
placeholder="e.g., 8080"
required
/>
</div>
)}
{toolTypeForm.interface_type === "terminal" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) =>
onFormChange({ startup_command: e.target.value })
}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
<small className="mobile-form-help">
Command to run before the interactive shell for each new
terminal session.
</small>
</div>
)}
{toolTypeForm.definition_type === "manifest" ? (
<div className="mobile-manifest-editor-wrapper">
<ManifestEditor
manifest={manifestData}
baseDefinitions={baseDefinitions}
onChange={(m) => onManifestChange(m)}
definitionId={manifestDefinitionId}
/>
</div>
) : (
<div className="mobile-form-group">
<label className="mobile-form-label">
{toolTypeForm.definition_type === "compose"
? "Compose Template *"
: "Dockerfile Template *"}
</label>
<textarea
value={templateValue}
onChange={(e) => {
if (toolTypeForm.definition_type === "compose") {
onFormChange({ compose_template: e.target.value });
} else {
onFormChange({ dockerfile_template: e.target.value });
}
}}
className="mobile-form-textarea mobile-form-code"
placeholder={
toolTypeForm.definition_type === "compose"
? "version: '3'"
: "FROM ubuntu:22.04"
}
rows={10}
required
/>
</div>
)}
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Readiness Command</label> <label className="mobile-form-label">Readiness Command</label>
<input <input
type="text" type="text"
value={toolTypeForm.readiness_command} value={toolTypeForm.readiness_command}
onChange={(e) => onChange={(e) => onFormChange({ readiness_command: e.target.value })}
onFormChange({ readiness_command: e.target.value })
}
className="mobile-form-input" className="mobile-form-input"
placeholder="e.g., curl -f http://localhost:8080/health" placeholder="e.g., curl -f http://localhost:8080/health"
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Readiness Timeout (seconds)</label> <label className="mobile-form-label">Readiness Timeout</label>
<input <input
type="number" type="text"
value={toolTypeForm.readiness_timeout} value={toolTypeForm.readiness_timeout}
onChange={(e) => onChange={(e) => onFormChange({ readiness_timeout: e.target.value })}
onFormChange({ readiness_timeout: e.target.value })
}
className="mobile-form-input" className="mobile-form-input"
placeholder="30" placeholder="30"
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Readiness Interval (seconds)</label> <label className="mobile-form-label">Readiness Interval</label>
<input <input
type="number" type="text"
value={toolTypeForm.readiness_interval} value={toolTypeForm.readiness_interval}
onChange={(e) => onChange={(e) => onFormChange({ readiness_interval: e.target.value })}
onFormChange({ readiness_interval: e.target.value })
}
className="mobile-form-input" className="mobile-form-input"
placeholder="2" placeholder="2"
/> />
</div> </div>
<div className="mobile-form-group"> <div className="mobile-form-group">
<label className="mobile-form-label">Required Variables</label> <label className="mobile-form-label">Required Variables</label>
<input <input
type="text" type="text"
value={toolTypeForm.required_variables} value={toolTypeForm.required_variables}
onChange={(e) => onChange={(e) => onFormChange({ required_variables: e.target.value })}
onFormChange({ required_variables: e.target.value })
}
className="mobile-form-input" className="mobile-form-input"
placeholder="VAR1, VAR2, VAR3" placeholder="VAR1, VAR2, VAR3"
/> />
</div> </div>
{toolTypeForm.definition_type === "compose" && (
{toolTypeError && ( <div className="mobile-form-group">
<p className="mobile-form-error"> <label className="mobile-form-label">Compose Template</label>
<Icon name="warning" size="sm" /> {toolTypeError} <textarea
</p> value={toolTypeForm.compose_template}
onChange={(e) => onFormChange({ compose_template: e.target.value })}
className="mobile-form-textarea mobile-form-code"
placeholder="version: '3'"
rows={10}
/>
</div>
)} )}
{toolTypeForm.definition_type === "dockerfile" && (
{toolTypeDirty && ( <div className="mobile-form-group">
<button <label className="mobile-form-label">Dockerfile Template</label>
type="button" <textarea
className="secondary-button mobile-discard-button" value={toolTypeForm.dockerfile_template}
onClick={onCancel} onChange={(e) =>
disabled={isSaving} onFormChange({ dockerfile_template: e.target.value })
> }
Discard Changes className="mobile-form-textarea mobile-form-code"
</button> placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
)} )}
</MobileEditView> </MobileEditView>
); );
@@ -449,16 +328,15 @@ export const ToolWorkshopMobileView = ({
items={toolTypes.map((t) => ({ items={toolTypes.map((t) => ({
id: t.id, id: t.id,
title: t.display_name, title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.definition_type} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`, subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))} }))}
onItemClick={(id) => { onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id); const toolType = toolTypes.find((t) => t.id === id);
if (toolType) { if (toolType) {
onSelect(toolType); onSelect(toolType);
onViewChange("edit"); onViewChange("detail");
} }
}} }}
onItemDelete={(id) => onDelete(id)}
emptyMessage="No tool types yet" emptyMessage="No tool types yet"
/> />
<MobileFAB <MobileFAB
@@ -342,7 +342,13 @@ export const InstanceList = ({
{sshKeys.map((key) => ( {sshKeys.map((key) => (
<label <label
key={key.id} key={key.id}
className="checkbox-label checkbox-label-compact" className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
> >
<input <input
type="checkbox" type="checkbox"
@@ -481,7 +487,13 @@ export const InstanceList = ({
{sshKeys.map((key) => ( {sshKeys.map((key) => (
<label <label
key={key.id} key={key.id}
className="checkbox-label checkbox-label-compact" className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
> >
<input <input
type="checkbox" type="checkbox"
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/hooks dir: apps/web/src/hooks
## role ## role
Provides reusable React custom hooks that encapsulate stateful logic, side effects, and API interactions for the web application's UI components, covering data fetching, terminal management, workspace operations, notifications, and mobile-specific behaviors. A collection of custom React hooks providing reusable state management, API integration, and UI behavior logic for the web application.
## parent ## parent
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md map: apps/web/src/.pi-map.md
+7 -7
View File
@@ -4,15 +4,15 @@ dir: apps/web/src/hooks
index: apps/web/src/hooks/.pi-map.index.md index: apps/web/src/hooks/.pi-map.index.md
## role ## role
Provides reusable React custom hooks that encapsulate stateful logic, side effects, and API interactions for the web application's UI components, covering data fetching, terminal management, workspace operations, notifications, and mobile-specific behaviors. A collection of custom React hooks providing reusable state management, API integration, and UI behavior logic for the web application.
## files ## files
- use-async-data.ts | A custom React hook that manages asynchronous data fetching with loading, error, and ready states, plus a manual reload capability. | exp: func:useAsyncData(fetcher: () => Promise<T>, deps: React.DependencyList) → UseAsyncDataResult<T>, call:useState, call:useCallback, call:setStatus, call:setError, call:fetcher, call:setData, call:load, call:useEffect | dep: react - use-async-data.ts | A custom React hook that manages asynchronous data fetching with loading, error, and ready states, plus a manual reload capability. | exp: func:useAsyncData(fetcher: () => Promise<T>, deps: React.DependencyList) → UseAsyncDataResult<T>, call:useState, call:useCallback, call:setStatus, call:setError, call:fetcher, call:setData, call:load, call:useEffect | dep: react
- use-auto-hide.ts | A React custom hook that automatically hides an element after a specified timeout and provides manual controls for showing, hiding, and toggling visibility. | exp: func:useAutoHide(options: AutoHideOptions), call:useState, call:useRef, call:Date.now, call:useCallback, call:setIsVisible, call:clearTimeout, call:setTimeout, call:hide, call:show, call:useEffect | dep: react - use-auto-hide.ts | A React custom hook that automatically hides an element after a specified timeout and provides manual controls for showing, hiding, and toggling visibility. | exp: func:useAutoHide(options: AutoHideOptions), call:useState, call:useRef, call:Date.now, call:useCallback, call:setIsVisible, call:clearTimeout, call:setTimeout, call:hide, call:show, call:useEffect | dep: react
- use-config-profiles.ts | A React custom hook that manages CRUD operations, form state, drag-and-drop ordering, and cyclic dependency prevention for configuration profiles. | exp: useConfigProfiles | dep: react, ../utils/errors, ../api/config-profiles, ../api/projects, ../api/tool-types, ../types - use-config-profiles.ts | A React custom hook that manages config profile CRUD operations, form state, profile inclusion with cycle detection, and drag-and-drop reordering. | exp: useConfigProfiles | dep: react, ../utils/errors, ../api/config-profiles, ../api/projects, ../api/tool-types, ../types
- use-events.test.ts | Tests a React hook that manages Server-Sent Events (SSE) connections with automatic reconnection, backoff, and error handling | dep: vitest, @testing-library/react, ./use-events, ../api/events, use-events hook - use-events.test.ts | Tests a React custom hook that manages Server-Sent Events connections with automatic reconnection, backoff strategies, and error handling. | dep: vitest, @testing-library/react, ./use-events, ../api/events, use-events hook, ../api/events module
- use-events.ts | React hook that manages a Server-Sent Events connection with automatic exponential backoff reconnection, lifecycle event handling, and authentication redirect on 401 errors. | exp: UseEventsReturn, func:useEvents() → UseEventsReturn, call:useState, call:useRef, call:useCallback, call:clearTimeout, call:createEventSource, call:setConnected, call:setError, call:setReconnectCount, call:JSON.parse, call:setEvents, call:es.addEventListener, call:es.close, call:Math.min, call:Math.pow, call:Math.random, call:setTimeout, call:probeEventStreamStatus, call:window.location.assign, call:connect, call:useEffect, call:esRef.current.close | dep: react, ../api/events, ../types/events - use-events.ts | React hook that manages a Server-Sent Events connection with exponential backoff reconnection, event buffering, and auth/rate-limit handling. | exp: UseEventsReturn, func:useEvents() → UseEventsReturn, call:useState, call:useRef, call:useCallback, call:clearTimeout, call:createEventSource, call:setConnected, call:setError, call:setReconnectCount, call:JSON.parse, call:setEvents, call:es.close, call:Math.min, call:Math.pow, call:Math.random, call:setTimeout, call:probeEventStreamStatus, call:window.location.assign, call:connect, call:useEffect, call:esRef.current.close | dep: react, ../api/events, ../types/events
- use-git-repo.ts | A custom React hook that centralizes all git repository operations (branch management, status tracking, commit history, and git actions) into a reusable interface for components. | exp: GitStatus, UseGitRepoResult, func:useGitRepo(projectId: string | undefined, repoId: string | undefined) → UseGitRepoResult, call:useState, call:useCallback, call:setLoading, call:setError, call:fn, call:extractError, call:withLoading, call:listRepositoryBranches, call:setBranches, call:data.branches.map, call:setDefaultBranch, call:getRepositoryStatus, call:setStatus, call:getRepositoryHistory, call:setHistory, call:getCommitDetail, call:setCommitDetail, call:commitChanges, call:refreshStatus, call:pushRepository, call:pullRepository, call:fetchRepository, call:checkoutBranch, call:refreshBranches, call:createBranch, call:deleteBranch, call:mergeBranches, call:refreshHistory, call:useEffect, raise:err | dep: react, ../api/git-repositories - use-git-repo.ts | A custom React hook that centralizes all git repository operations (branch management, status tracking, commit history, and git actions) into a reusable interface for components. | exp: GitStatus, UseGitRepoResult, func:useGitRepo(projectId: string | undefined, repoId: string | undefined) → UseGitRepoResult, call:useState, call:useCallback, call:setLoading, call:setError, call:fn, call:extractError, call:withLoading, call:listRepositoryBranches, call:setBranches, call:data.branches.map, call:setDefaultBranch, call:getRepositoryStatus, call:setStatus, call:getRepositoryHistory, call:setHistory, call:getCommitDetail, call:setCommitDetail, call:commitChanges, call:refreshStatus, call:pushRepository, call:pullRepository, call:fetchRepository, call:checkoutBranch, call:refreshBranches, call:createBranch, call:deleteBranch, call:mergeBranches, call:refreshHistory, call:useEffect, raise:err | dep: react, ../api/git-repositories
- use-instance-actions.ts | A React custom hook that manages instance/session actions including opening, starting, stopping, deleting, force-deleting, recreating tunnels, and renaming with loading states and dirty delete handling. | exp: func:useInstanceActions(options: UseInstanceActionsOptions) → UseInstanceActionsReturn, call:useSessions, call:useState, call:useRef, call:useCallback, call:tabRefs.current.get, call:existing.focus, call:session.tool_type_interfaces?.includes, call:window.open, call:tabRefs.current.set, call:setLoadingSessionId, call:startInstance, call:onRefresh, call:stopInstance, call:deleteInstance, call:setDirtyDeleteSession, call:setDirtyDeleteFiles, call:removeSession, call:recreateInstanceTunnel, call:alert, call:newName.trim, call:renameInstance | dep: react, ../api/sessions, ../state/sessions - use-instance-actions.ts | A custom React hook that provides a centralized interface for managing instance/session actions including opening, starting, stopping, deleting, force-deleting, recreating tunnels, and renaming, with loading states and dirty delete handling for conflict resolution. | exp: func:useInstanceActions(options: UseInstanceActionsOptions) → UseInstanceActionsReturn, call:useSessions, call:useSessionOperations, call:useState, call:useRef, call:useCallback, call:tabRefs.current.get, call:existing.focus, call:session.tool_type_interfaces?.includes, call:window.open, call:tabRefs.current.set, call:setLoadingSessionId, call:startOperation, call:startInstance, call:onRefresh, call:completeOperation, call:stopInstance, call:deleteInstance, call:setDirtyDeleteSession, call:setDirtyDeleteFiles, call:removeSession, call:recreateInstanceTunnel, call:alert, call:newName.trim, call:renameInstance | dep: react, ../api/sessions, ../state/sessions, ../state/session-operations
- use-mobile-viewport.ts | A React hook that tracks whether the viewport width is below a mobile breakpoint (768px) | exp: func:useMobileViewport(), call:useState, call:useEffect, call:setIsMobile, call:window.addEventListener, call:window.removeEventListener | dep: react - use-mobile-viewport.ts | A React hook that tracks whether the viewport width is below a mobile breakpoint (768px) | exp: func:useMobileViewport(), call:useState, call:useEffect, call:setIsMobile, call:window.addEventListener, call:window.removeEventListener | dep: react
- use-notifications.test.tsx | Tests the useNotifications custom React hook with mocked API calls, covering optimistic updates, polling behavior, and error handling. | dep: vitest, @testing-library/react, ./use-notifications, ../state/notifications, ../api/notifications, use-notifications - use-notifications.test.tsx | Tests the useNotifications custom React hook with mocked API calls, covering optimistic updates, polling behavior, and error handling. | dep: vitest, @testing-library/react, ./use-notifications, ../state/notifications, ../api/notifications, use-notifications
- use-notifications.ts | Custom React hook that provides access to notification state and ensures it's used within a NotificationProvider | exp: func:useNotifications(), call:useContext, raise:Error | dep: react, ../state/notifications - use-notifications.ts | Custom React hook that provides access to notification state and ensures it's used within a NotificationProvider | exp: func:useNotifications(), call:useContext, raise:Error | dep: react, ../state/notifications
@@ -23,7 +23,7 @@ Provides reusable React custom hooks that encapsulate stateful logic, side effec
- use-terminal-page.ts | Manages terminal page state including sessions, keyboard shortcuts, fullscreen mode, mobile viewport handling, and terminal lifecycle operations. | exp: useTerminalPage | dep: react, react-router-dom, ../components/features/terminal/terminal, ../components/features/terminal/terminal-session-tabs, ./use-mobile-viewport, ./use-auto-hide, ./use-virtual-keyboard, ./use-terminal-sessions, ../api/terminal, ./use-special-keys, use-mobile-viewport, use-auto-hide, use-virtual-keyboard, use-terminal-sessions, terminal, terminal-session-tabs, api/terminal, api/sessions, use-special-keys - use-terminal-page.ts | Manages terminal page state including sessions, keyboard shortcuts, fullscreen mode, mobile viewport handling, and terminal lifecycle operations. | exp: useTerminalPage | dep: react, react-router-dom, ../components/features/terminal/terminal, ../components/features/terminal/terminal-session-tabs, ./use-mobile-viewport, ./use-auto-hide, ./use-virtual-keyboard, ./use-terminal-sessions, ../api/terminal, ./use-special-keys, use-mobile-viewport, use-auto-hide, use-virtual-keyboard, use-terminal-sessions, terminal, terminal-session-tabs, api/terminal, api/sessions, use-special-keys
- use-terminal-sessions.ts | React custom hook that manages terminal session state (CRUD operations, active session tracking) for a given instance | exp: UseTerminalSessionsResult, func:useTerminalSessions(instanceId: string) → UseTerminalSessionsResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listTerminalSessions, call:setSessions, call:setActiveSessionId, call:createTerminalSession, call:closeTerminalSession, call:prev.filter, call:renameTerminalSession, call:prev.map, call:resetTerminalSession, call:loadSessions, call:useEffect | dep: react, ../api/terminal - use-terminal-sessions.ts | React custom hook that manages terminal session state (CRUD operations, active session tracking) for a given instance | exp: UseTerminalSessionsResult, func:useTerminalSessions(instanceId: string) → UseTerminalSessionsResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listTerminalSessions, call:setSessions, call:setActiveSessionId, call:createTerminalSession, call:closeTerminalSession, call:prev.filter, call:renameTerminalSession, call:prev.map, call:resetTerminalSession, call:loadSessions, call:useEffect | dep: react, ../api/terminal
- use-theme.ts | React hook that fetches user theme preference on mount and applies it to the document root element via data-theme attribute | exp: func:useTheme(), call:useEffect, call:getUserConfig, call:document.documentElement.removeAttribute, call:document.documentElement.setAttribute | dep: react, ../api/settings - use-theme.ts | React hook that fetches user theme preference on mount and applies it to the document root element via data-theme attribute | exp: func:useTheme(), call:useEffect, call:getUserConfig, call:document.documentElement.removeAttribute, call:document.documentElement.setAttribute | dep: react, ../api/settings
- use-tool-workshop.ts | Custom React hook that manages state and operations for a tool workshop UI, including CRUD operations for tool types and tool definitions with form handling and validation. | exp: useToolWorkshop | dep: react, ../utils/errors, ../api/tool-types, ../api/tool-definitions, ../components/features/tool-workshop/ToolTypeEditorPanel - use-tool-workshop.ts | React custom hook that manages state and operations for a tool workshop UI, including CRUD operations for tool types and tool definitions with form handling and validation. | exp: useToolWorkshop | dep: react, ../utils/errors, ../api/tool-types, ../api/tool-definitions, ../components/features/tool-workshop/ToolTypeEditorPanel
- use-virtual-keyboard.ts | React hook that detects virtual keyboard open/close state and measures its height on mobile devices | exp: func:useVirtualKeyboard(), call:useState, call:useCallback, call:setState, call:useEffect, call:visualViewport.addEventListener, call:window.addEventListener, call:updateKeyboardState, call:visualViewport.removeEventListener, call:window.removeEventListener | dep: react - use-virtual-keyboard.ts | React hook that detects virtual keyboard open/close state and measures its height on mobile devices | exp: func:useVirtualKeyboard(), call:useState, call:useCallback, call:setState, call:useEffect, call:visualViewport.addEventListener, call:window.addEventListener, call:updateKeyboardState, call:visualViewport.removeEventListener, call:window.removeEventListener | dep: react
- use-workspace-actions.ts | Provides a React hook that encapsulates workspace CRUD operations with loading state management and user confirmation dialogs for destructive actions. | exp: UseWorkspaceActionsResult, func:useWorkspaceActions() → UseWorkspaceActionsResult, call:useState, call:useCallback, call:createWorkspace, call:setLoadingId, call:deleteWorkspace, call:onRefresh, call:window.confirm, call:instances.map((i) => `- ${i.name}`).join, call:syncWorkspace, call:updateWorkspace, raise:err | dep: react, ../api/workspaces, ../types/workspace - use-workspace-actions.ts | Provides a React hook that encapsulates workspace CRUD operations with loading state management and user confirmation dialogs for destructive actions. | exp: UseWorkspaceActionsResult, func:useWorkspaceActions() → UseWorkspaceActionsResult, call:useState, call:useCallback, call:createWorkspace, call:setLoadingId, call:deleteWorkspace, call:onRefresh, call:window.confirm, call:instances.map((i) => `- ${i.name}`).join, call:syncWorkspace, call:updateWorkspace, raise:err | dep: react, ../api/workspaces, ../types/workspace
- use-workspace-files.ts | Custom React hook for managing workspace file operations including listing, loading, saving, and navigating files. | exp: UseWorkspaceFilesResult, func:useWorkspaceFiles(workspaceId: string) → UseWorkspaceFilesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaceFiles, call:setEntries, call:setCurrentPath, call:setContent, call:getWorkspaceFileContent, call:saveWorkspaceFile, call:refresh, call:useEffect | dep: react, ../api/workspace-files - use-workspace-files.ts | Custom React hook for managing workspace file operations including listing, loading, saving, and navigating files. | exp: UseWorkspaceFilesResult, func:useWorkspaceFiles(workspaceId: string) → UseWorkspaceFilesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaceFiles, call:setEntries, call:setCurrentPath, call:setContent, call:getWorkspaceFileContent, call:saveWorkspaceFile, call:refresh, call:useEffect | dep: react, ../api/workspace-files
@@ -31,7 +31,7 @@ Provides reusable React custom hooks that encapsulate stateful logic, side effec
- use-workspace-instances.ts | Custom React hook for managing workspace instances with CRUD operations, loading states, and error handling. | exp: UseWorkspaceInstancesResult, func:useWorkspaceInstances(workspaceId: string) → UseWorkspaceInstancesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaceInstances, call:setInstances, call:createWorkspaceInstance, call:refresh, call:useEffect | dep: react, ../api/workspace-instances, ../api/sessions - use-workspace-instances.ts | Custom React hook for managing workspace instances with CRUD operations, loading states, and error handling. | exp: UseWorkspaceInstancesResult, func:useWorkspaceInstances(workspaceId: string) → UseWorkspaceInstancesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaceInstances, call:setInstances, call:createWorkspaceInstance, call:refresh, call:useEffect | dep: react, ../api/workspace-instances, ../api/sessions
- use-workspaces.ts | Custom React hook that fetches and manages workspace data with loading and error states. | exp: UseWorkspacesResult, func:useWorkspaces(projectId: string, repoId: string) → UseWorkspacesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaces, call:listAllWorkspaces, call:setWorkspaces, call:useEffect, call:refresh | dep: react, ../api/workspaces, ../types/workspace - use-workspaces.ts | Custom React hook that fetches and manages workspace data with loading and error states. | exp: UseWorkspacesResult, func:useWorkspaces(projectId: string, repoId: string) → UseWorkspacesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaces, call:listAllWorkspaces, call:setWorkspaces, call:useEffect, call:refresh | dep: react, ../api/workspaces, ../types/workspace
## arch ## arch
Follows a custom hook composition pattern where each hook encapsulates a single domain concern (data fetching, CRUD operations, UI state, device detection), often combining useState/useEffect/useCallback with SWR-style async data management, and layering higher-level hooks (use-terminal-page) from lower-level ones (use-mobile-viewport, use-virtual-keyboard). Follows a feature-based composition pattern where each hook encapsulates a specific domain concern (data fetching, CRUD operations, terminal/session management, UI interactions), often combining React state with API calls, side effects, and provider context integration.
## tags ## tags
call:set, call:use, workspace, react, state, terminal, api, callback call:set, call:use, workspace, react, state, terminal, api, callback
## symbols ## symbols
+2 -4
View File
@@ -193,7 +193,7 @@ export const useConfigProfiles = () => {
setDragOverIndex(null); setDragOverIndex(null);
}; };
const handleSubmit = async (e?: React.FormEvent): Promise<boolean> => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
setError(null); setError(null);
setSaveStatus("saving"); setSaveStatus("saving");
@@ -201,7 +201,7 @@ export const useConfigProfiles = () => {
if (!formData.name?.trim()) { if (!formData.name?.trim()) {
setError("Name is required"); setError("Name is required");
setSaveStatus("error"); setSaveStatus("error");
return false; return;
} }
try { try {
@@ -224,11 +224,9 @@ export const useConfigProfiles = () => {
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id); const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
if (refreshed) populateForm(refreshed); if (refreshed) populateForm(refreshed);
} }
return true;
} catch (err) { } catch (err) {
setError(extractErrorMessage(err)); setError(extractErrorMessage(err));
setSaveStatus("error"); setSaveStatus("error");
return false;
} }
}; };
+305 -328
View File
@@ -10,359 +10,336 @@ import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "./use-special-keys"; import type { ModifierKey } from "./use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] => const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({ sessions.map((s) => ({
id: s.id, id: s.id,
name: s.name, name: s.name,
status: s.status as TerminalSessionInfo["status"], status: s.status as TerminalSessionInfo["status"],
})); }));
type TerminalStatus = type TerminalStatus =
| "connecting" | "connecting"
| "connected" | "connected"
| "disconnected" | "disconnected"
| "error" | "error"
| "resetting"; | "resetting";
export const useTerminalPage = () => { export const useTerminalPage = () => {
const { instanceId } = useParams<{ instanceId: string }>(); const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useMobileViewport(); const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({}); const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const [terminalStatuses, setTerminalStatuses] = useState< const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus> Record<string, TerminalStatus>
>({}); >({});
const changeFontSizeRefs = useRef< const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
Record<string, ((delta: number) => void) | null> const sendDataRef = useRef<((data: string) => void) | null>(null);
>({}); const focusInputRef = useRef<(() => void) | null>(null);
const sendDataRefs = useRef<Record<string, ((data: string) => void) | null>>( const [showResetConfirm, setShowResetConfirm] = useState(false);
{}, const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
); const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
const focusInputRefs = useRef<Record<string, (() => void) | null>>({}); null,
const [showResetConfirm, setShowResetConfirm] = useState(false); );
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false); const { isOpen: isKeyboardOpen, height: keyboardHeight } =
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>( useVirtualKeyboard();
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [instanceInfo, setInstanceInfo] = useState<{ const [instanceInfo, setInstanceInfo] = useState<{
display_name: string; display_name: string;
workspace_name?: string | null; workspace_name?: string | null;
tool_type_name: string; tool_type_name: string;
} | null>(null); } | null>(null);
const { const {
sessions, sessions,
activeSessionId, activeSessionId,
setActiveSessionId, setActiveSessionId,
createSession, createSession,
closeSession, closeSession,
renameSession, renameSession,
resetSession, resetSession,
loading, loading,
error, error,
} = useTerminalSessions(instanceId ?? ""); } = useTerminalSessions(instanceId ?? "");
// Fetch instance details for tab title // Fetch instance details for tab title
useEffect(() => { useEffect(() => {
if (!instanceId) return; if (!instanceId) return;
const load = async () => { const load = async () => {
try { try {
const { getUserSessions } = await import("../api/sessions"); const { getUserSessions } = await import("../api/sessions");
const allSessions = await getUserSessions(); const allSessions = await getUserSessions();
const match = allSessions.find((s) => s.id === instanceId); const match = allSessions.find((s) => s.id === instanceId);
if (match) { if (match) {
setInstanceInfo({ setInstanceInfo({
display_name: match.display_name, display_name: match.display_name,
workspace_name: match.workspace_name, workspace_name: match.workspace_name,
tool_type_name: match.tool_type_name, tool_type_name: match.tool_type_name,
}); });
} }
} catch { } catch {
// ignore // ignore
} }
}; };
void load(); void load();
}, [instanceId]); }, [instanceId]);
// Auto-create default session // Auto-create default session
useEffect(() => { useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) { if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1"); void createSession("Session 1");
} }
}, [loading, sessions.length, error, instanceId, createSession]); }, [loading, sessions.length, error, instanceId, createSession]);
// Update document title based on active terminal session // Update document title based on active terminal session
useEffect(() => { useEffect(() => {
if (!instanceId) { if (!instanceId) {
document.title = "Terminal"; document.title = "Terminal";
return; return;
} }
const active = sessions.find((s) => s.id === activeSessionId); const active = sessions.find((s) => s.id === activeSessionId);
const baseName = instanceInfo const baseName = instanceInfo
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}` ? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
: `Instance ${instanceId.slice(0, 8)}`; : `Instance ${instanceId.slice(0, 8)}`;
if (sessions.length <= 1) { if (sessions.length <= 1) {
document.title = baseName; document.title = baseName;
} else { } else {
const sessionName = active?.name ?? "Session"; const sessionName = active?.name ?? "Session";
document.title = `${baseName} ${sessionName}`; document.title = `${baseName} ${sessionName}`;
} }
return () => { return () => {
document.title = "Headquarter"; document.title = "Headquarter";
}; };
}, [instanceId, activeSessionId, sessions, instanceInfo]); }, [instanceId, activeSessionId, sessions, instanceInfo]);
// Sync refs with sessions // Sync refs with sessions
useEffect(() => { useEffect(() => {
for (const session of sessions) { for (const session of sessions) {
if (!terminalRefs.current[session.id]) { if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>(); terminalRefs.current[session.id] = React.createRef<TerminalRef>();
} }
} }
const currentIds = new Set(sessions.map((s) => s.id)); const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) { for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) { if (!currentIds.has(id)) {
delete terminalRefs.current[id]; delete terminalRefs.current[id];
delete sendDataRefs.current[id]; }
delete focusInputRefs.current[id]; }
delete changeFontSizeRefs.current[id]; }, [sessions]);
}
}
setTerminalStatuses((prev) => {
const next: Record<string, TerminalStatus> = {};
for (const id of currentIds) {
if (prev[id]) {
next[id] = prev[id];
}
}
return next;
});
}, [sessions]);
// Fit and focus active terminal // Fit and focus active terminal
useEffect(() => { useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) { if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId]; const ref = terminalRefs.current[activeSessionId];
let raf1 = 0; let raf1 = 0;
let raf2 = 0; let raf2 = 0;
raf1 = requestAnimationFrame(() => { raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => {
ref.current?.fit(); ref.current?.fit();
ref.current?.focus(); ref.current?.focus();
}); });
}); });
return () => { return () => {
cancelAnimationFrame(raf1); cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2); cancelAnimationFrame(raf2);
}; };
} }
}, [activeSessionId]); }, [activeSessionId]);
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey; const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return; if (!isAltShift) return;
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case "n": case "n":
e.preventDefault(); e.preventDefault();
if (sessions.length < 5) { if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`); void createSession(`Session ${sessions.length + 1}`);
} }
break; break;
case "w": case "w":
e.preventDefault(); e.preventDefault();
if ( if (
activeSessionId && activeSessionId &&
window.confirm("Close this terminal session?") window.confirm("Close this terminal session?")
) { ) {
void closeSession(activeSessionId); void closeSession(activeSessionId);
} }
break; break;
case "arrowleft": case "arrowleft":
e.preventDefault(); e.preventDefault();
if (activeSessionId) { if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId); const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id); if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
} }
break; break;
case "arrowright": case "arrowright":
e.preventDefault(); e.preventDefault();
if (activeSessionId) { if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId); const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) if (idx < sessions.length - 1)
setActiveSessionId(sessions[idx + 1].id); setActiveSessionId(sessions[idx + 1].id);
} }
break; break;
case "r": case "r":
e.preventDefault(); e.preventDefault();
if (activeSessionId) void resetSession(activeSessionId); if (activeSessionId) void resetSession(activeSessionId);
break; break;
case "f": case "f":
e.preventDefault(); e.preventDefault();
setIsFullscreen((prev) => !prev); setIsFullscreen((prev) => !prev);
break; break;
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [ }, [
sessions, sessions,
activeSessionId, activeSessionId,
createSession, createSession,
closeSession, closeSession,
resetSession, resetSession,
setActiveSessionId, setActiveSessionId,
]); ]);
// Keep screen awake // Keep screen awake
useEffect(() => { useEffect(() => {
let wakeLock: WakeLockSentinel | null = null; let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => { const requestWakeLock = async () => {
try { try {
if ("wakeLock" in navigator) { if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen"); wakeLock = await navigator.wakeLock.request("screen");
} }
} catch { } catch {
// ignore // ignore
} }
}; };
void requestWakeLock(); void requestWakeLock();
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void requestWakeLock(); if (document.visibilityState === "visible") void requestWakeLock();
}; };
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {}); wakeLock?.release().catch(() => {});
}; };
}, []); }, []);
// Lock page scroll on mobile // Lock page scroll on mobile
useEffect(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open"); document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open"); document.body.classList.add("terminal-page-open");
return () => { return () => {
document.documentElement.classList.remove("terminal-page-open"); document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open"); document.body.classList.remove("terminal-page-open");
}; };
}, [isMobile]); }, [isMobile]);
const handleFullscreenClick = useCallback( const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => { (e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return; if (!isFullscreen) return;
const target = e.target as Node; const target = e.target as Node;
const current = e.currentTarget as HTMLElement; const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content"); const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header"); const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) return; if (content?.contains(target) || header?.contains(target)) return;
setIsFullscreen(false); setIsFullscreen(false);
}, },
[isFullscreen], [isFullscreen],
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(sessionId: string) => setActiveSessionId(sessionId), (sessionId: string) => setActiveSessionId(sessionId),
[setActiveSessionId], [setActiveSessionId],
); );
const handleClose = useCallback( const handleClose = useCallback(
async (sessionId: string) => closeSession(sessionId), async (sessionId: string) => closeSession(sessionId),
[closeSession], [closeSession],
); );
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`); void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]); }, [createSession, sessions.length]);
const handleRename = useCallback( const handleRename = useCallback(
(sessionId: string, newName: string) => { (sessionId: string, newName: string) => {
void renameSession(sessionId, newName); void renameSession(sessionId, newName);
}, },
[renameSession], [renameSession],
); );
const handleTerminalReady = useCallback( const handleTerminalReady = useCallback(
( (
sessionId: string | undefined, sendData: (data: string) => void,
sendData: (data: string) => void, status: TerminalStatus,
status: TerminalStatus, focusInput: () => void,
focusInput: () => void, changeFontSize: (delta: number) => void,
changeFontSize: (delta: number) => void, ) => {
) => { setTerminalStatuses((prev) => ({
const key = sessionId ?? "default"; ...prev,
setTerminalStatuses((prev) => ({ [activeSessionId ?? "default"]: status,
...prev, }));
[key]: status, sendDataRef.current = sendData;
})); focusInputRef.current = focusInput;
sendDataRefs.current[key] = sendData; changeFontSizeRef.current = changeFontSize;
focusInputRefs.current[key] = focusInput; },
changeFontSizeRefs.current[key] = changeFontSize; [activeSessionId],
}, );
[],
);
const handleFontSizeChange = useCallback( const handleFontSizeChange = useCallback((delta: number) => {
(delta: number) => { changeFontSizeRef.current?.(delta);
const key = activeSessionId ?? "default"; }, []);
changeFontSizeRefs.current[key]?.(delta);
},
[activeSessionId],
);
const handleSendKey = useCallback( const handleSendKey = useCallback((data: string) => {
(data: string) => { sendDataRef.current?.(data);
const key = activeSessionId ?? "default"; }, []);
sendDataRefs.current[key]?.(data);
},
[activeSessionId],
);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) { if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset(); terminalRefs.current[activeSessionId].current?.reset();
} }
}, [activeSessionId]); }, [activeSessionId]);
return { return {
instanceId, instanceId,
navigate, navigate,
isMobile, isMobile,
isFullscreen, isFullscreen,
setIsFullscreen, setIsFullscreen,
terminalRefs, terminalRefs,
headerAutoHide, headerAutoHide,
terminalStatuses, terminalStatuses,
showResetConfirm, sendDataRef,
setShowResetConfirm, focusInputRef,
showSpecialKeysPanel, changeFontSizeRef,
setShowSpecialKeysPanel, showResetConfirm,
activeModifier, setShowResetConfirm,
setActiveModifier, showSpecialKeysPanel,
isKeyboardOpen, setShowSpecialKeysPanel,
keyboardHeight, activeModifier,
sessions, setActiveModifier,
activeSessionId, isKeyboardOpen,
setActiveSessionId, keyboardHeight,
loading, sessions,
error, activeSessionId,
handleFullscreenClick, setActiveSessionId,
handleSelect, loading,
handleClose, error,
handleCreate, handleFullscreenClick,
handleRename, handleSelect,
handleTerminalReady, handleClose,
handleFontSizeChange, handleCreate,
handleSendKey, handleRename,
handleReset, handleTerminalReady,
sessionInfos: SESSIONS_TO_INFO(sessions), handleFontSizeChange,
}; handleSendKey,
handleReset,
sessionInfos: SESSIONS_TO_INFO(sessions),
};
}; };
+5 -7
View File
@@ -151,13 +151,13 @@ export const useToolWorkshop = () => {
resetToolTypeForm(); resetToolTypeForm();
}; };
const handleToolTypeSubmit = async (e?: React.FormEvent): Promise<boolean> => { const handleToolTypeSubmit = async (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
setToolTypeError(null); setToolTypeError(null);
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) { if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
setToolTypeError("Name and display name are required"); setToolTypeError("Name and display name are required");
return false; return;
} }
if ( if (
@@ -166,7 +166,7 @@ export const useToolWorkshop = () => {
isNaN(Number(toolTypeForm.default_port))) isNaN(Number(toolTypeForm.default_port)))
) { ) {
setToolTypeError("Default port is required and must be a number"); setToolTypeError("Default port is required and must be a number");
return false; return;
} }
if (toolTypeForm.definition_type !== "manifest") { if (toolTypeForm.definition_type !== "manifest") {
@@ -179,13 +179,13 @@ export const useToolWorkshop = () => {
setToolTypeError( setToolTypeError(
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`, `${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
); );
return false; return;
} }
} else if (!manifestData) { } else if (!manifestData) {
setToolTypeError( setToolTypeError(
"Manifest data is required for manifest definition type", "Manifest data is required for manifest definition type",
); );
return false; return;
} }
const variables = toolTypeForm.required_variables const variables = toolTypeForm.required_variables
@@ -307,10 +307,8 @@ export const useToolWorkshop = () => {
setToolTypeDirty(false); setToolTypeDirty(false);
} }
await loadData(); await loadData();
return true;
} catch (err) { } catch (err) {
setToolTypeError(extractErrorMessage(err)); setToolTypeError(extractErrorMessage(err));
return false;
} }
}; };
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/pages dir: apps/web/src/pages
## role ## role
Contains top-level page components that render the main UI views for the web application, each corresponding to a distinct route and feature area. Contains top-level page components that serve as route endpoints for the web application's primary UI surfaces, each handling a specific domain area (dashboard, projects, workspaces, git, settings, etc.) with responsive layouts and CRUD operations.
## parent ## parent
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md map: apps/web/src/.pi-map.md
+8 -8
View File
@@ -4,30 +4,30 @@ dir: apps/web/src/pages
index: apps/web/src/pages/.pi-map.index.md index: apps/web/src/pages/.pi-map.index.md
## role ## role
Contains top-level page components that render the main UI views for the web application, each corresponding to a distinct route and feature area. Contains top-level page components that serve as route endpoints for the web application's primary UI surfaces, each handling a specific domain area (dashboard, projects, workspaces, git, settings, etc.) with responsive layouts and CRUD operations.
## files ## files
- ConfigProfilesPage.tsx | Renders a configuration profiles management page with responsive desktop/mobile layouts for creating, editing, previewing, and organizing config profiles. | exp: ConfigProfilesPage | dep: react, ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-config-profiles, ../components/features/config-profiles/ConfigProfileListSidebar, ../components/features/config-profiles/ConfigProfileEditorPanel, ../components/features/config-profiles/ConfigProfilesMobileView, use-mobile-viewport, use-config-profiles, data-states, ConfigProfileListSidebar, ConfigProfileEditorPanel, ConfigProfilesMobileView - ConfigProfilesPage.tsx | Renders a responsive configuration profiles management page with sidebar list and editor panel for desktop, and a dedicated mobile view for creating, editing, and managing config profiles. | exp: ConfigProfilesPage | dep: react, ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-config-profiles, ../components/features/config-profiles/ConfigProfileListSidebar, ../components/features/config-profiles/ConfigProfileEditorPanel, ../components/features/config-profiles/ConfigProfilesMobileView
- DashboardPage.test.tsx | Unit tests for the DashboardPage component verifying overview loading and error retry behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./DashboardPage, ../state/sessions, DashboardPage, SessionsProvider - DashboardPage.test.tsx | Unit tests for the DashboardPage component verifying overview loading and error retry behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./DashboardPage, ../state/sessions, DashboardPage, SessionsProvider
- DashboardPage.tsx | Renders a dashboard homepage that displays workspace overview, active/recent sessions, summary statistics, and polling health checks for running instances. | exp: HomePage | dep: react, react-router-dom, ../api/dashboard, ../api/sessions, ../components/data-states, ../components/features/session/session-list, ../hooks/use-instance-actions, ../state/sessions - DashboardPage.tsx | Renders a dashboard homepage that displays workspace overview, active/recent sessions, summary statistics, and polling health checks for running instances. | exp: HomePage | dep: react, react-router-dom, ../api/dashboard, ../api/sessions, ../components/data-states, ../components/features/session/session-list, ../hooks/use-instance-actions, ../state/sessions
- GitHistoryPage.tsx | Renders a Git commit history page with branch selection, commit list with graph visualization, and a detail panel showing commit metadata, stats, and diffs. | exp: GitHistoryPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../hooks/use-async-data - GitHistoryPage.tsx | Renders a Git commit history page with branch selection, commit list with graph visualization, and a detail panel showing commit metadata, stats, and diffs. | exp: GitHistoryPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../hooks/use-async-data
- GitRepositoriesPage.tsx | Displays and manages a project's Git repositories with CRUD operations including listing, creating, navigating to history, and deleting with confirmation | exp: GitRepositoriesPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../components/features/project/repository-create-dialog, ../hooks/use-async-data - GitRepositoriesPage.tsx | Displays and manages a project's Git repositories with CRUD operations including listing, creating, navigating to history, and deleting with confirmation | exp: GitRepositoriesPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../components/features/project/repository-create-dialog, ../hooks/use-async-data
- PlaceholderPage.tsx | Exports three simple React page components (PlaceholderPage, NotFoundPage, LoginRedirectPage) for a frontend scaffold. | exp: PlaceholderPage, NotFoundPage, LoginRedirectPage | dep: ../components/icon, React - PlaceholderPage.tsx | Exports three simple React page components (PlaceholderPage, NotFoundPage, LoginRedirectPage) for a frontend scaffold. | exp: PlaceholderPage, NotFoundPage, LoginRedirectPage | dep: ../components/icon, React
- ProfilePage.tsx | A React component that renders a responsive profile page with form editing, avatar upload, and save functionality for both mobile and desktop viewports. | exp: ProfilePage | dep: react, ../api/profile, ../components/data-states, ../components/icon, ../components/features/profile/ProfileMobileView, ../state/auth, ../hooks/use-async-data, ../hooks/use-mobile-viewport - ProfilePage.tsx | A React component that displays and allows editing of a user profile, including name, email, and avatar upload with validation. | exp: ProfilePage | dep: react, ../api/profile, ../components/data-states, ../components/icon, ../state/auth, ../hooks/use-async-data
- ProjectSettingsPage.tsx | Renders a project settings page with tabbed navigation for general settings, repositories, and members, including project data fetching, editing, and deletion capabilities. | exp: ProjectSettingsPage | dep: react, react-router-dom, ../components/features/settings/settings-tab-layout, ../components/features/project/repositories-settings-tab, ../api/client, ../types - ProjectSettingsPage.tsx | Renders a project settings page with tabbed navigation for general settings, repositories, and members, including project data fetching, editing, and deletion capabilities. | exp: ProjectSettingsPage | dep: react, react-router-dom, ../components/features/settings/settings-tab-layout, ../components/features/project/repositories-settings-tab, ../api/client, ../types
- ProjectsPage.test.tsx | Unit tests for the ProjectsPage component covering loading, empty, error, create, edit, and delete states with API mocking. | dep: @testing-library/react, react-router-dom, vitest, ./ProjectsPage, ../api/projects, ProjectsPage - ProjectsPage.test.tsx | Unit tests for the ProjectsPage component covering loading, empty, error, create, edit, and delete states with API mocking. | dep: @testing-library/react, react-router-dom, vitest, ./ProjectsPage, ../api/projects, ProjectsPage
- ProjectsPage.tsx | Renders a responsive projects management page with separate mobile and desktop layouts, supporting project CRUD operations, repository management, and workspace actions. | exp: ProjectsPage | dep: react, ../components/data-states, ../components/icon, ../hooks/use-mobile-viewport, ../components/features/project/ProjectCard, ../components/features/project/ProjectDialog, ../components/features/project/repository-create-dialog, ../components/features/mobile/mobile-list-view, ../components/features/mobile/mobile-fab, ../hooks/use-projects, ../types, use-mobile-viewport, use-projects, data-states, icon, ProjectCard, ProjectDialog, RepositoryCreateDialog, MobileListView, MobileFAB - ProjectsPage.tsx | Renders a responsive projects management page with separate mobile and desktop layouts, supporting project CRUD operations, repository management, and workspace actions. | exp: ProjectsPage | dep: react, ../components/data-states, ../components/icon, ../hooks/use-mobile-viewport, ../components/features/project/ProjectCard, ../components/features/project/ProjectDialog, ../components/features/project/repository-create-dialog, ../components/features/mobile/mobile-list-view, ../components/features/mobile/mobile-fab, ../hooks/use-projects, ../types
- SessionsPage.tsx | Renders a sessions management page that displays, polls health for, and handles CRUD operations on development environment sessions with dirty delete confirmation. | exp: SessionsPage | dep: react, ../api/sessions, ../api/settings, ../components/data-states, ../components/features/session/session-list, ../components/features/session/session-card, ../hooks/use-instance-actions, ../state/sessions - SessionsPage.tsx | Renders a sessions management page that displays, polls health for, and handles CRUD operations on development environment sessions with dirty delete confirmation. | exp: SessionsPage | dep: react, ../api/sessions, ../api/settings, ../components/data-states, ../components/features/session/session-list, ../components/features/session/session-card, ../hooks/use-instance-actions, ../state/sessions
- SettingsPage.tsx | A React settings page component that loads, displays, and manages user configuration with tabbed navigation and nested outlet for child routes. | exp: SettingsPage | dep: react, react-router-dom, ../api/settings, ../components/data-states, ../hooks/use-async-data, ../components/features/settings/GeneralSettingsTab - SettingsPage.tsx | A React settings page component that loads, displays, and manages user configuration with tabbed navigation and nested outlet for child routes. | exp: SettingsPage | dep: react, react-router-dom, ../api/settings, ../components/data-states, ../hooks/use-async-data, ../components/features/settings/GeneralSettingsTab
- SshKeysPage.tsx | React page component for managing SSH keys including generation, listing, signing, verification, and deletion | exp: SSHKeysPage | dep: react-router-dom, ../components/data-states, ../hooks/use-ssh-keys, ../components/features/ssh-keys/SSHKeyCreateForm, ../components/features/ssh-keys/SSHKeyList - SshKeysPage.tsx | React page component for managing SSH keys including generation, listing, signing, verification, and deletion | exp: SSHKeysPage | dep: react-router-dom, ../components/data-states, ../hooks/use-ssh-keys, ../components/features/ssh-keys/SSHKeyCreateForm, ../components/features/ssh-keys/SSHKeyList
- TerminalPage.tsx | Renders a responsive terminal page that switches between mobile and desktop views based on device type, managing terminal sessions and their interactions. | exp: TerminalPage | dep: react, ../hooks/use-terminal-page, ../components/features/terminal/MobileTerminalView, ../components/features/terminal/DesktopTerminalView, useTerminalPage hook, MobileTerminalView, DesktopTerminalView - TerminalPage.tsx | Renders a responsive terminal page that switches between mobile and desktop views based on device type, managing terminal sessions and their interactions. | exp: TerminalPage | dep: react, ../hooks/use-terminal-page, ../components/features/terminal/MobileTerminalView, ../components/features/terminal/DesktopTerminalView, useTerminalPage hook, MobileTerminalView, DesktopTerminalView
- ToolWorkshopPage.tsx | Renders a responsive tool workshop page with list and editor views for managing tool types, handling loading/error states and mobile/desktop layouts | exp: ToolWorkshopPage | dep: ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-tool-workshop, ../components/features/tool-workshop/ToolTypeListSidebar, ../components/features/tool-workshop/ToolTypeEditorPanel, ../components/features/tool-workshop/ToolWorkshopMobileView, react, use-mobile-viewport, use-tool-workshop, data-states, ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView - ToolWorkshopPage.tsx | Renders a responsive tool workshop page with sidebar/editor layout for desktop and tabbed mobile view for managing tool types | exp: ToolWorkshopPage | dep: ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-tool-workshop, ../components/features/tool-workshop/ToolTypeListSidebar, ../components/features/tool-workshop/ToolTypeEditorPanel, ../components/features/tool-workshop/ToolWorkshopMobileView, react, use-mobile-viewport, use-tool-workshop, data-states, ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView
- WorkspaceDetailPage.test.tsx | Tests the WorkspaceDetailPage component rendering and tab switching behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./WorkspaceDetailPage, @testing-library/jest-dom, WorkspaceDetailPage, use-workspaces, use-workspace-files, use-workspace-git, use-workspace-instances, use-mobile-viewport - WorkspaceDetailPage.test.tsx | Tests the WorkspaceDetailPage component rendering and tab switching behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./WorkspaceDetailPage, @testing-library/jest-dom, WorkspaceDetailPage, use-workspaces, use-workspace-files, use-workspace-git, use-workspace-instances, use-mobile-viewport
- WorkspaceDetailPage.tsx | Renders a workspace detail page with tab-based navigation for files, git, tools, and settings panels, with mobile-responsive layout. | exp: func:WorkspaceDetailPage(), call:useParams, call:useState, call:useMobileViewport, call:useWorkspaces, call:workspaces.find | dep: react, react-router-dom, ../hooks/use-workspaces, ../hooks/use-mobile-viewport, ../components/features/workspace/workspace-detail-header, ../components/features/workspace/workspace-tab-bar, ../components/features/workspace/workspace-file-panel, ../components/features/workspace/workspace-git-panel, ../components/features/workspace/workspace-tools-panel, ../components/features/workspace/workspace-settings-panel, use-workspaces, use-mobile-viewport, workspace-detail-header, workspace-tab-bar, workspace-file-panel, workspace-git-panel, workspace-tools-panel, workspace-settings-panel - WorkspaceDetailPage.tsx | Renders a workspace detail page with tab-based navigation for files, git, tools, and settings panels, with mobile-responsive layout. | exp: func:WorkspaceDetailPage(), call:useParams, call:useState, call:useMobileViewport, call:useWorkspaces, call:workspaces.find | dep: react, react-router-dom, ../hooks/use-workspaces, ../hooks/use-mobile-viewport, ../components/features/workspace/workspace-detail-header, ../components/features/workspace/workspace-tab-bar, ../components/features/workspace/workspace-file-panel, ../components/features/workspace/workspace-git-panel, ../components/features/workspace/workspace-tools-panel, ../components/features/workspace/workspace-settings-panel, use-workspaces, use-mobile-viewport, workspace-detail-header, workspace-tab-bar, workspace-file-panel, workspace-git-panel, workspace-tools-panel, workspace-settings-panel
- WorkspacesPage.tsx | Renders a responsive workspaces management page with mobile and desktop views supporting listing, creating, viewing details, syncing, deleting, and starting tools for workspaces. | exp: func:WorkspacesPage(), call:useMobileViewport, call:useState, call:useWorkspaces, call:useWorkspaceActions, call:actions.delete, call:actions.sync, call:setMobileView, call:refresh, call:setStartWorkspace, call:handleDelete, call:setSelectedWorkspace, call:workspaces.map, call:e.stopPropagation, call:setShowCreate | dep: react, ../components/icon, ../hooks/use-mobile-viewport, ../hooks/use-workspaces, ../hooks/use-workspace-actions, ../components/features/workspace/workspace-card, ../components/features/workspace/workspace-create-form, ../components/features/mobile/mobile-detail-view, ../components/features/mobile/mobile-fab, ../components/features/tool/tool-starter, ../types/workspace - WorkspacesPage.tsx | Renders a responsive workspaces management page with separate mobile and desktop layouts, supporting workspace listing, creation, deletion, sync, and tool launching. | exp: func:WorkspacesPage(), call:useMobileViewport, call:useState, call:useWorkspaces, call:useWorkspaceActions, call:actions.delete, call:actions.sync, call:setMobileView, call:refresh, call:setStartWorkspace, call:handleDelete, call:setSelectedWorkspace, call:workspaces.map, call:e.stopPropagation, call:setShowCreate | dep: react, ../components/icon, ../hooks/use-mobile-viewport, ../hooks/use-workspaces, ../hooks/use-workspace-actions, ../components/features/workspace/workspace-card, ../components/features/workspace/workspace-create-form, ../components/features/mobile/mobile-detail-view, ../components/features/mobile/mobile-fab, ../components/features/tool/tool-starter, ../types/workspace
## arch ## arch
Route-based page organization with responsive mobile/desktop layout patterns, tabbed navigation for complex pages, polling/health-check mechanisms for live data, and CRUD operations with loading/error/empty states throughout. Follows a page-based routing architecture where each file maps to a URL route, using responsive design patterns with explicit mobile/desktop view branching, compound component layouts (sidebar/editor, tabbed panels), polling for real-time data, and direct API integration within page components rather than abstracted service layers.
## tags ## tags
page, components, workspace, features, mobile, react, hooks, settings page, components, workspace, features, react, mobile, settings, hooks
## symbols ## symbols
- WorkspaceDetailPage - WorkspaceDetailPage
- WorkspacesPage - WorkspacesPage
+1 -25
View File
@@ -6,7 +6,7 @@ import { ConfigProfileListSidebar } from "../components/features/config-profiles
import { ConfigProfileEditorPanel } from "../components/features/config-profiles/ConfigProfileEditorPanel"; import { ConfigProfileEditorPanel } from "../components/features/config-profiles/ConfigProfileEditorPanel";
import { ConfigProfilesMobileView } from "../components/features/config-profiles/ConfigProfilesMobileView"; import { ConfigProfilesMobileView } from "../components/features/config-profiles/ConfigProfilesMobileView";
type MobileView = "list" | "detail" | "edit" | "preview"; type MobileView = "list" | "detail" | "edit";
export const ConfigProfilesPage = () => { export const ConfigProfilesPage = () => {
const isMobile = useMobileViewport(); const isMobile = useMobileViewport();
@@ -100,18 +100,11 @@ export const ConfigProfilesPage = () => {
return ( return (
<ConfigProfilesMobileView <ConfigProfilesMobileView
profiles={profiles} profiles={profiles}
projects={projects}
toolTypes={toolTypes}
selectedProfile={selectedProfile} selectedProfile={selectedProfile}
mobileView={mobileView} mobileView={mobileView}
isCreating={isCreating} isCreating={isCreating}
formData={formData} formData={formData}
includedProfileIds={includedProfileIds}
availableProfiles={availableProfilesForInclude()}
previewData={previewData}
previewingId={previewingId}
saveStatus={saveStatus} saveStatus={saveStatus}
error={error}
onViewChange={setMobileView} onViewChange={setMobileView}
onSelect={handleSelectProfile} onSelect={handleSelectProfile}
onCreate={handleCreateNew} onCreate={handleCreateNew}
@@ -119,23 +112,6 @@ export const ConfigProfilesPage = () => {
onFormChange={updateFormField} onFormChange={updateFormField}
onSubmit={handleSubmit} onSubmit={handleSubmit}
getScopeLabel={getScopeLabel} getScopeLabel={getScopeLabel}
getIncludedProfile={getIncludedProfile}
onAddInclude={addInclude}
onRemoveInclude={removeInclude}
onAddEnvVar={addEnvVar}
onUpdateEnvVar={updateEnvVar}
onRemoveEnvVar={removeEnvVar}
onAddFile={addFile}
onUpdateFile={updateFile}
onRemoveFile={removeFile}
onAddMount={addMount}
onUpdateMount={updateMount}
onRemoveMount={removeMount}
onAddMountFile={addMountFile}
onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile}
onPreview={handlePreview}
onClosePreview={() => setPreviewData(null)}
/> />
); );
} }
+3 -22
View File
@@ -3,16 +3,13 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile"; import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { ProfileMobileView } from "../components/features/profile/ProfileMobileView";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import type { UserProfile } from "../api/profile"; import type { UserProfile } from "../api/profile";
type ProfileStatus = "loading" | "ready" | "error" | "saving"; type ProfileStatus = "loading" | "ready" | "error" | "saving";
export const ProfilePage = () => { export const ProfilePage = () => {
const isMobile = useMobileViewport();
const { refreshSession } = useAuth(); const { refreshSession } = useAuth();
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []); const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading"); const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
@@ -93,30 +90,14 @@ export const ProfilePage = () => {
return ( return (
<section className="stack"> <section className="stack">
{!isMobile && <h1>Profile</h1>} <h1>Profile</h1>
{displayStatus === "loading" && <LoadingState message="Loading profile..." />} {displayStatus === "loading" && <LoadingState message="Loading profile..." />}
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />} {displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
{(displayStatus === "ready" || displayStatus === "saving") && profile && ( {(displayStatus === "ready" || displayStatus === "saving") && profile && (
isMobile ? ( <div className="card stack">
<ProfileMobileView
profile={profile}
name={name}
email={email}
error={error}
isSaving={displayStatus === "saving"}
avatarUrl={avatarUrl}
fileInputRef={fileInputRef}
onNameChange={setName}
onEmailChange={setEmail}
onAvatarButtonClick={() => fileInputRef.current?.click()}
onAvatarChange={handleAvatarChange}
onSave={() => void handleSave()}
/>
) : (
<div className="card stack">
<div className="profile-avatar-section"> <div className="profile-avatar-section">
<div className="avatar-preview"> <div className="avatar-preview">
{avatarUrl ? ( {avatarUrl ? (
@@ -197,7 +178,7 @@ export const ProfilePage = () => {
</button> </button>
</div> </div>
</div> </div>
))} )}
</section> </section>
); );
}; };
+1 -9
View File
@@ -57,20 +57,12 @@ export const ToolWorkshopPage = () => {
mobileView={mobileView} mobileView={mobileView}
isCreating={isCreating} isCreating={isCreating}
toolTypeForm={toolTypeForm} toolTypeForm={toolTypeForm}
manifestData={manifestData}
manifestDefinitionId={manifestDefinitionId}
baseDefinitions={baseDefinitions}
toolTypeError={toolTypeError} toolTypeError={toolTypeError}
toolTypeDirty={toolTypeDirty}
onViewChange={setMobileView} onViewChange={setMobileView}
onSelect={handleSelectToolType} onSelect={handleSelectToolType}
onCreate={handleCreateNew} onCreate={handleCreateNew}
onDelete={handleDeleteToolType} onDelete={handleDeleteToolType}
onFormChange={handleFormChange} onFormChange={handleFormChange}
onManifestChange={(m) => {
setManifestData(m);
setToolTypeDirty(true);
}}
onSubmit={handleToolTypeSubmit} onSubmit={handleToolTypeSubmit}
onCancel={() => { onCancel={() => {
if (toolTypeDirty) { if (toolTypeDirty) {
@@ -78,7 +70,7 @@ export const ToolWorkshopPage = () => {
return; return;
} }
} }
setMobileView("list"); setMobileView(isCreating ? "list" : "detail");
}} }}
/> />
); );
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/styles dir: apps/web/src/styles
## role ## role
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, theme tokens, utility classes, and component-specific styles. Provides the complete visual design system and styling foundation for the web application, encompassing global styles, design tokens, utility classes, and component-specific styles.
## parent ## parent
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md map: apps/web/src/.pi-map.md

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