Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 355b471067 | |||
| a2f0847aad | |||
| 82ff8b8801 | |||
| a6ad8268b9 | |||
| 3358af57c2 | |||
| 6a61669294 | |||
| 29e48cdb65 | |||
| ed1f7805f6 | |||
| 1d345eba32 | |||
| e35e605914 | |||
| 94137c6586 | |||
| 1658767cf4 | |||
| 83928d0f02 | |||
| 8f648264f1 | |||
| b26ed7c3e4 | |||
| 6e33e8e4e9 | |||
| f0ae9483f3 | |||
| 90992e46a8 | |||
| 41f9427224 | |||
| 089d802f1d | |||
| a4e6c46a47 | |||
| 47de2a0133 | |||
| bd94cc9bbf | |||
| fe82a248ec | |||
| c8db6ce933 | |||
| 896674195c | |||
| ddd92e3dd4 | |||
| b4203a4a09 | |||
| 5fc8e035e6 | |||
| e6114ed18c | |||
| ac9f7a9299 | |||
| 84cf423684 | |||
| 4c14966ae3 | |||
| 6c31ef9577 | |||
| a12d6a8169 | |||
| 13b3c60abc | |||
| 23fc0a6b82 | |||
| 8b15689fb3 | |||
| 98d4393387 | |||
| 35f0a3ea2e | |||
| 1ae8d0e45f | |||
| 025ccc5817 | |||
| 350b393457 |
+1
-1
@@ -16,7 +16,7 @@ dir: .
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
|
||||
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling.
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
|
||||
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling.
|
||||
## files
|
||||
- .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
|
||||
@@ -31,7 +31,7 @@ Infrastructure and deployment configuration package for a self-hosted project ma
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
docker, redis, git, application, postgresql, compose, traefik, project
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps
|
||||
|
||||
## role
|
||||
Contains the main deployable application modules or entry points for the project.
|
||||
Container for deployable application entry points and top-level configurations in the project.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ dir: apps
|
||||
index: apps/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains the main deployable application modules or entry points for the project.
|
||||
Container for deployable application entry points and top-level configurations in the project.
|
||||
## files
|
||||
## arch
|
||||
Modular monolith or microservices architecture with separate application boundaries, each potentially having its own configuration, dependencies, and lifecycle.
|
||||
Monorepo-style directory structure housing independently runnable applications that share common libraries or modules, typically with per-app configuration, dependencies, and build targets.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
dir: apps/api
|
||||
|
||||
## role
|
||||
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
|
||||
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools by orchestrating Docker instances for remote development environments.
|
||||
## parent
|
||||
index: apps/.pi-map.index.md
|
||||
map: apps/.pi-map.md
|
||||
## 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
|
||||
index: apps/api/.pi-lens/.pi-map.index.md
|
||||
map: apps/api/.pi-lens/.pi-map.md
|
||||
- apps/api/.pytest_cache
|
||||
index: apps/api/.pytest_cache/.pi-map.index.md
|
||||
map: apps/api/.pytest_cache/.pi-map.md
|
||||
- apps/api/.ruff_cache
|
||||
index: apps/api/.ruff_cache/.pi-map.index.md
|
||||
map: apps/api/.ruff_cache/.pi-map.md
|
||||
- apps/api/.venv-test
|
||||
index: apps/api/.venv-test/.pi-map.index.md
|
||||
map: apps/api/.venv-test/.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ dir: apps/api
|
||||
index: apps/api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
|
||||
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools by orchestrating Docker instances for remote development environments.
|
||||
## 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
|
||||
- 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
|
||||
- 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
|
||||
Async Python/FastAPI with PostgreSQL (Alembic migrations), multi-stage Docker deployment with Cloudflare tunneling, uv package management, and containerized service orchestration.
|
||||
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.
|
||||
## tags
|
||||
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/alembic
|
||||
|
||||
## role
|
||||
Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
|
||||
Database migration infrastructure for the API application, providing version-controlled schema changes with async SQLAlchemy support.
|
||||
## parent
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
|
||||
@@ -4,12 +4,12 @@ dir: apps/api/alembic
|
||||
index: apps/api/alembic/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
|
||||
Database migration infrastructure for the API application, providing version-controlled schema changes with async SQLAlchemy support.
|
||||
## 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
|
||||
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQLAlchemy schema migrations | dep: alembic, sqlalchemy
|
||||
## arch
|
||||
Alembic migration framework with Mako templating for generating revision scripts, async SQLAlchemy engine configuration, and autogenerate capabilities for schema change tracking.
|
||||
Alembic migration framework with Mako templating for generating migration scripts, configured for async SQLAlchemy operations.
|
||||
## tags
|
||||
migrations, run, sqlalchemy, async, alembic, call:context.configure, call:context.begin, transaction
|
||||
## symbols
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
dir: apps/api/alembic/versions
|
||||
|
||||
## role
|
||||
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.
|
||||
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.
|
||||
## parent
|
||||
index: apps/api/alembic/.pi-map.index.md
|
||||
map: apps/api/alembic/.pi-map.md
|
||||
## 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
|
||||
- 0001_initial_schema.py
|
||||
- 0002_refresh_tokens.py
|
||||
@@ -48,6 +50,9 @@ map: apps/api/alembic/.pi-map.md
|
||||
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py
|
||||
- 2026_06_01_add_workspaces.py
|
||||
- 2026_06_13_make_clone_mode_nullable.py
|
||||
- 2026_06_14_104415_add_tool_type_home_directory.py
|
||||
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
|
||||
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
|
||||
- 398082499c30_add_tool_config_fields.py
|
||||
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
|
||||
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
|
||||
@@ -55,6 +60,7 @@ map: apps/api/alembic/.pi-map.md
|
||||
- 8ed7dd80973d_create_config_folders_table.py
|
||||
- af8512103d67_add_tool_type_fields.py
|
||||
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py
|
||||
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py
|
||||
## links
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
map: apps/api/alembic/versions/.pi-map.md
|
||||
@@ -65,5 +71,7 @@ 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
|
||||
- change versions config
|
||||
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
|
||||
-
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: apps/api/alembic/versions
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
|
||||
## role
|
||||
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.
|
||||
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.
|
||||
## 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
|
||||
- 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 @@ Manages incremental database schema evolution for the API application using Alem
|
||||
- 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)
|
||||
- 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 "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
|
||||
- 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
|
||||
- 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_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 @@ Manages incremental database schema evolution for the API application using Alem
|
||||
- 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_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-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_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_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_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,18 +44,22 @@ Manages incremental database schema evolution for the API application using Alem
|
||||
- 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_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 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_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_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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
column, table, call:op.drop, downgrade, alembic, upgrade, key, call:sa.text
|
||||
column, table, call:op.drop, alembic, downgrade, upgrade, key, call:sa.text
|
||||
## symbols
|
||||
- upgrade
|
||||
- downgrade
|
||||
@@ -72,5 +76,7 @@ column, table, call:op.drop, downgrade, alembic, upgrade, key, call:sa.text
|
||||
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||
- change versions config
|
||||
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||
- explore versions subdirectories
|
||||
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -104,11 +104,11 @@ RUN git config --global init.defaultBranch main \\
|
||||
&& git config --global user.name "Developer"
|
||||
|
||||
# Create default tmux config
|
||||
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
|
||||
# Create default ranger config
|
||||
RUN mkdir -p /home/user/.config/ranger \\
|
||||
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||
|
||||
# Set up Pi config directory
|
||||
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.name "Developer"
|
||||
|
||||
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
|
||||
RUN mkdir -p /home/user/.config/ranger \\
|
||||
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||
|
||||
RUN mkdir -p /home/user/.pi/agent
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""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
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src
|
||||
|
||||
## role
|
||||
Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
|
||||
Core application package for the Headquarter API, handling configuration, database connectivity, logging infrastructure, and FastAPI application lifecycle.
|
||||
## parent
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: apps/api/src
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
|
||||
Core application package for the Headquarter API, handling configuration, database connectivity, logging infrastructure, and FastAPI application lifecycle.
|
||||
## files
|
||||
- __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
|
||||
@@ -12,7 +12,7 @@ Core API application package that initializes and configures the Headquarter Fas
|
||||
- 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.*
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
src, database, logging, call:logger.info, api, middleware, fastapi, filter
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/api
|
||||
|
||||
## role
|
||||
Defines shared API infrastructure including reusable Pydantic validators for consistent input validation across API endpoints.
|
||||
Defines the API routing package and shared validation utilities for the REST API layer.
|
||||
## parent
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
|
||||
@@ -4,12 +4,12 @@ dir: apps/api/src/api
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines shared API infrastructure including reusable Pydantic validators for consistent input validation across API endpoints.
|
||||
Defines the API routing package and shared validation utilities for the REST API layer.
|
||||
## files
|
||||
- __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
|
||||
## arch
|
||||
Modular utility package with functional validation helpers using Pydantic for declarative schema enforcement.
|
||||
Modular package structure with reusable Pydantic validators for cross-cutting API schema concerns.
|
||||
## tags
|
||||
validate, raise:value, error, call:isinstance, mount, api, init, path
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/api/workspace
|
||||
|
||||
## role
|
||||
Provides FastAPI REST endpoints for workspace management, file operations, git integration, and tool instance management within user workspaces.
|
||||
Provides FastAPI REST API endpoints for workspace management, including file operations, git version control, and tool instance orchestration within user-scoped development environments.
|
||||
## parent
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
|
||||
@@ -4,15 +4,15 @@ dir: apps/api/src/api/workspace
|
||||
index: apps/api/src/api/workspace/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides FastAPI REST endpoints for workspace management, file operations, git integration, and tool instance management within user workspaces.
|
||||
Provides FastAPI REST API endpoints for workspace management, including file operations, git version control, and tool instance orchestration within user-scoped development environments.
|
||||
## 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
|
||||
- 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_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 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
|
||||
- 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
|
||||
## arch
|
||||
Modular FastAPI router pattern with domain-driven separation (files, git, instances, workspaces) using nested URL structures and git-backed workspace synchronization.
|
||||
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.
|
||||
## tags
|
||||
workspace, get, raise:httpexception, call:, at.isoformat, git, call:select, call:data.get
|
||||
## symbols
|
||||
|
||||
@@ -134,12 +134,18 @@ async def create_workspace_top_level(
|
||||
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||
session.add(workspace)
|
||||
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:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
status_code=500,
|
||||
detail=f"Failed to create workspace: {exc}",
|
||||
) from exc
|
||||
|
||||
await session.refresh(workspace)
|
||||
@@ -241,8 +247,8 @@ async def create_workspace(
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
status_code=500,
|
||||
detail=f"Failed to create workspace: {exc}",
|
||||
) from exc
|
||||
|
||||
await session.refresh(workspace)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/models
|
||||
|
||||
## role
|
||||
Centralized database model definitions and shared infrastructure for the API's data layer.
|
||||
Provides the SQLAlchemy ORM data models and database schema definitions for the API application.
|
||||
## parent
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
|
||||
@@ -4,12 +4,12 @@ dir: apps/api/src/models
|
||||
index: apps/api/src/models/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Centralized database model definitions and shared infrastructure for the API's data layer.
|
||||
Provides the SQLAlchemy ORM data models and database schema definitions for the API application.
|
||||
## 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
|
||||
- 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
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
models, src, base, project, system, user, mixin, tool
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/models/tool
|
||||
|
||||
## role
|
||||
Database models for containerized tool lifecycle management, covering tool definitions, deployment instances, and categorization types.
|
||||
Provides SQLAlchemy ORM models for managing containerized tool definitions, types, and deployed instances in the API.
|
||||
## parent
|
||||
index: apps/api/src/models/.pi-map.index.md
|
||||
map: apps/api/src/models/.pi-map.md
|
||||
|
||||
@@ -4,16 +4,16 @@ dir: apps/api/src/models/tool
|
||||
index: apps/api/src/models/tool/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Database models for containerized tool lifecycle management, covering tool definitions, deployment instances, and categorization types.
|
||||
Provides SQLAlchemy ORM models for managing containerized tool definitions, types, and deployed instances in the API.
|
||||
## 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
|
||||
- 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_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
|
||||
- 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
|
||||
## arch
|
||||
SQLAlchemy ORM with declarative models using inheritance hierarchies, relationship mappings, and polymorphic manifest compilation for Docker/Compose deployment.
|
||||
Domain-driven data models using SQLAlchemy ORM with declarative base pattern, entity relationships, and inheritance support for tool manifest definitions.
|
||||
## tags
|
||||
tool, models, src, sqlalchemy, orm, definition, manifest, base
|
||||
tool, models, src, sqlalchemy, orm, definition, base, manifest
|
||||
## symbols
|
||||
- ToolDefinitionManifest
|
||||
- ToolInstance
|
||||
|
||||
@@ -39,6 +39,9 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
)
|
||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, 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(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/seeds
|
||||
|
||||
## role
|
||||
Provides database seeding utilities for initializing built-in tool type configurations in the API application.
|
||||
Provides database seeding utilities for initializing and synchronizing built-in data records in the API application.
|
||||
## parent
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
|
||||
@@ -4,12 +4,12 @@ dir: apps/api/src/seeds
|
||||
index: apps/api/src/seeds/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides database seeding utilities for initializing built-in tool type configurations in the API application.
|
||||
Provides database seeding utilities for initializing and synchronizing built-in data records in the API application.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for database seeding utilities.
|
||||
- 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
|
||||
- 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
|
||||
## arch
|
||||
Simple procedural seeding script using SQLAlchemy upsert operations to populate reference data for containerized development environment templates.
|
||||
Simple imperative seeding scripts with upsert pattern for idempotent data initialization, using direct database operations without abstraction layers.
|
||||
## tags
|
||||
tool, types, table, exists, builtin, call:tool, database, init
|
||||
## symbols
|
||||
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
- {{REPO_PATH}}:/config/workspace
|
||||
ports:
|
||||
- "8443:8443"
|
||||
restart: unless-stopped""",
|
||||
restart: 'no'""",
|
||||
"default_port": 8443,
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
- {{REPO_PATH}}:/home/jovyan/work
|
||||
ports:
|
||||
- "8888:8888"
|
||||
restart: unless-stopped""",
|
||||
restart: 'no'""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
@@ -122,7 +122,7 @@ services:
|
||||
exec tail -f /dev/null"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped""",
|
||||
restart: 'no'""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/services
|
||||
|
||||
## role
|
||||
Marks the services directory as a Python package for business logic layer components.
|
||||
Service layer package for the API application, intended to contain business logic implementations.
|
||||
## parent
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: apps/api/src/services
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a Python package namespace for organizing service-layer modules in the API application.
|
||||
Service layer package for the API application, intended to contain business logic implementations.
|
||||
## files
|
||||
- __init__.py | Empty file with no functionality
|
||||
## arch
|
||||
Standard Python package structure using __init__.py for directory-based module organization, following conventional layered architecture patterns.
|
||||
Standard Python package structure with an empty initializer, awaiting service module implementations following a layered architecture pattern.
|
||||
## tags
|
||||
init, empty, functionality
|
||||
## symbols
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from src.services.config.config_profile_resolver import expand_container_path
|
||||
from src.services.docker import sort_volumes_by_specificity
|
||||
|
||||
|
||||
@@ -142,13 +143,6 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||
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 = manifest.get("packages", {}).get("pip", [])
|
||||
if pip_packages:
|
||||
@@ -158,6 +152,9 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
|
||||
# User creation
|
||||
user = manifest.get("user")
|
||||
home_dir = get_manifest_home_dir(manifest)
|
||||
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
|
||||
npm_prefix = ""
|
||||
if user:
|
||||
name = user["name"]
|
||||
uid = user["uid"]
|
||||
@@ -168,22 +165,21 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
||||
lines.append("")
|
||||
# Set HOME and USER for runtime compatibility
|
||||
home = f"/home/{name}"
|
||||
lines.append(f"ENV HOME={home}")
|
||||
lines.append(f"ENV HOME={home_dir}")
|
||||
lines.append(f"ENV USER={name}")
|
||||
lines.append("")
|
||||
# Ensure home directory exists and is writable by the user.
|
||||
# Recursively chown so any files copied from /etc/skel by useradd -m
|
||||
# (e.g. .bashrc, .config) are owned by the container user.
|
||||
lines.append(
|
||||
f"RUN mkdir -p {home} && chown -R {name}:{name} {home} && chmod 755 {home}"
|
||||
f"RUN mkdir -p {home_dir} && chown -R {name}:{name} {home_dir} && chmod 755 {home_dir}"
|
||||
)
|
||||
# Pre-create common config directories so apps like ranger can write
|
||||
# their configs on first run without permission errors.
|
||||
common_dirs = [".config", ".local/share", ".cache"]
|
||||
for d in common_dirs:
|
||||
lines.append(
|
||||
f"RUN mkdir -p {home}/{d} && chown -R {name}:{name} {home}/{d}"
|
||||
f"RUN mkdir -p {home_dir}/{d} && chown -R {name}:{name} {home_dir}/{d}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
@@ -193,6 +189,20 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
)
|
||||
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 = manifest.get("scripts", {}).get("build", [])
|
||||
for script in build_scripts:
|
||||
@@ -208,19 +218,38 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
|
||||
# After build scripts, ensure everything in home is owned by the user
|
||||
if user and build_scripts:
|
||||
lines.append(f"RUN chown -R {name}:{name} {home}")
|
||||
lines.append(f"RUN chown -R {name}:{name} {home_dir}")
|
||||
lines.append("")
|
||||
|
||||
# Create mount target directories
|
||||
# Create mount target directories that do NOT depend on runtime variables.
|
||||
# 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", [])
|
||||
if mounts:
|
||||
dirs = [mount["target"] for mount in mounts]
|
||||
dir_str = " ".join(dirs)
|
||||
static_dirs = [
|
||||
mount["target"] for mount in mounts
|
||||
if "{{WORKSPACE_NAME}}" not in mount.get("target", "")
|
||||
]
|
||||
if static_dirs:
|
||||
dir_str = " ".join(static_dirs)
|
||||
lines.append(f"RUN mkdir -p {dir_str}")
|
||||
if user:
|
||||
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
|
||||
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
|
||||
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||
if startup_scripts:
|
||||
@@ -230,14 +259,22 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
|
||||
lines.append("")
|
||||
|
||||
# Switch to runtime user
|
||||
if user:
|
||||
lines.append(f"USER {user['name']}")
|
||||
lines.append(f"WORKDIR /home/{user['name']}")
|
||||
lines.append("")
|
||||
# Do not switch to the runtime user in the Dockerfile. The entrypoint
|
||||
# starts as root so it can create the /workspace compatibility symlink
|
||||
# (which lives under /) and fix mount ownership, then it drops privileges
|
||||
# to the container user before exec-ing the real command.
|
||||
|
||||
# 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
|
||||
runtime = manifest.get("runtime", {})
|
||||
if startup_scripts:
|
||||
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
|
||||
|
||||
@@ -251,6 +288,11 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
def compile_entrypoint(manifest: dict) -> str:
|
||||
"""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:
|
||||
manifest: Fully resolved manifest JSON.
|
||||
|
||||
@@ -259,12 +301,108 @@ def compile_entrypoint(manifest: dict) -> str:
|
||||
"""
|
||||
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", [])
|
||||
for script in startup_scripts:
|
||||
lines.append(script)
|
||||
lines.append("")
|
||||
|
||||
lines.append('exec "$@"')
|
||||
# Drop from root to the container user before running the real command.
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -281,11 +419,18 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
runtime = manifest.get("runtime", {})
|
||||
user = manifest.get("user")
|
||||
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] = {
|
||||
"image": variables["IMAGE_TAG"],
|
||||
"container_name": variables["INSTANCE_NAME"],
|
||||
"restart": "unless-stopped",
|
||||
"restart": "no",
|
||||
}
|
||||
|
||||
# Terminal-specific fields
|
||||
@@ -294,11 +439,16 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
if runtime.get("tty", False):
|
||||
service["tty"] = True
|
||||
if runtime.get("working_dir"):
|
||||
service["working_dir"] = runtime["working_dir"]
|
||||
service["working_dir"] = expand_container_path(
|
||||
runtime["working_dir"], home_dir
|
||||
)
|
||||
|
||||
# User override
|
||||
# The entrypoint starts as root (Dockerfile does not set USER) so it can
|
||||
# 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:
|
||||
service["user"] = f"{user['uid']}:{user['gid']}"
|
||||
service["user"] = "0:0"
|
||||
|
||||
# Ports for web tools
|
||||
default_port = manifest.get("default_port")
|
||||
@@ -310,6 +460,12 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
if 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
|
||||
extra_env = variables.get("EXTRA_ENV", {})
|
||||
if extra_env:
|
||||
@@ -319,14 +475,25 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
|
||||
# Volumes from mount schema
|
||||
volumes = []
|
||||
has_explicit_repo_mount = False
|
||||
for mount in manifest.get("mounts", []):
|
||||
source = resolve_mount_source(mount, variables)
|
||||
if not source:
|
||||
continue
|
||||
target = mount["target"]
|
||||
if mount.get("source_type") == "repo":
|
||||
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 ""
|
||||
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
|
||||
for vol in variables.get("EXTRA_VOLUMES", []):
|
||||
vol_str = f"{vol['source']}:{vol['target']}"
|
||||
@@ -385,7 +552,12 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def get_manifest_home_dir(manifest: dict) -> str:
|
||||
"""Get the home directory for a container based on manifest user config.
|
||||
"""Get the home directory for a container based on manifest 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:
|
||||
manifest: Fully resolved manifest JSON.
|
||||
@@ -393,6 +565,10 @@ def get_manifest_home_dir(manifest: dict) -> str:
|
||||
Returns:
|
||||
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")
|
||||
if user and user.get("name"):
|
||||
return f"/home/{user['name']}"
|
||||
@@ -402,6 +578,10 @@ def get_manifest_home_dir(manifest: dict) -> str:
|
||||
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||
"""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:
|
||||
tool_name: Human-readable tool name.
|
||||
manifest: Fully resolved manifest JSON.
|
||||
@@ -409,9 +589,11 @@ def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||
Returns:
|
||||
Docker image tag string.
|
||||
"""
|
||||
# Canonicalize: sort keys, stable JSON
|
||||
compiler_version = "v4" # bump when compile_dockerfile/entrypoint/compose change
|
||||
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||
hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
|
||||
hash_suffix = hashlib.sha256(
|
||||
f"{compiler_version}:{canonical}".encode()
|
||||
).hexdigest()[:8]
|
||||
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
|
||||
return f"headquarter/{safe_name}-{hash_suffix}:latest"
|
||||
|
||||
|
||||
@@ -172,6 +172,47 @@ def _merge_mounts(
|
||||
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(
|
||||
base: list[dict[str, Any]],
|
||||
overlay: list[dict[str, Any]],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/services/docker
|
||||
|
||||
## role
|
||||
Provides Docker infrastructure services for container lifecycle management, compose orchestration, configuration deployment, and secure tunneling to expose internal services.
|
||||
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling.
|
||||
## parent
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
|
||||
@@ -4,15 +4,15 @@ dir: apps/api/src/services/docker
|
||||
index: apps/api/src/services/docker/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides Docker infrastructure services for container lifecycle management, compose orchestration, configuration deployment, and secure tunneling to expose internal services.
|
||||
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling.
|
||||
## 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
|
||||
- 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
|
||||
- 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
|
||||
- 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 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
|
||||
- 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
|
||||
- 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
|
||||
Subprocess-based CLI wrapper architecture around Docker/cloudflared tools with template rendering, path-traversal-safe file staging, and functional decomposition into single-responsibility modules.
|
||||
Subprocess-based CLI wrapper architecture with template-driven file generation, security-validated file staging, and container-orchestrated tunnel proxying.
|
||||
## tags
|
||||
tunnel, container, call:subprocess.run, get, name, call:, network, call:result.stdout.strip
|
||||
## symbols
|
||||
|
||||
@@ -61,6 +61,19 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
Container logs (stdout + stderr)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
@@ -291,9 +291,15 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
if result.returncode != 0:
|
||||
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:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/services/instance
|
||||
|
||||
## role
|
||||
Provides infrastructure for managing tool instance lifecycle events, health monitoring, and asynchronous communication within the API service.
|
||||
Coordinates tool instance lifecycle events, health monitoring, and notifications across the API service.
|
||||
## parent
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
|
||||
@@ -4,14 +4,14 @@ dir: apps/api/src/services/instance
|
||||
index: apps/api/src/services/instance/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides infrastructure for managing tool instance lifecycle events, health monitoring, and asynchronous communication within the API service.
|
||||
Coordinates tool instance lifecycle events, health monitoring, and notifications across the API service.
|
||||
## 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
|
||||
- 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 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
|
||||
- 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
|
||||
- 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
|
||||
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.
|
||||
Observer pattern via typed singleton event bus with async/sync subscribers, background polling loops, and side-effect hooks for persistence and notifications.
|
||||
## tags
|
||||
call:self., instance, src, services, event, health, call:logger.exception, check
|
||||
## symbols
|
||||
|
||||
@@ -203,6 +203,11 @@ class HealthMonitor:
|
||||
# Transient states (created, restarting) — preserve current status
|
||||
# instead of treating them as an error. The next poll will resolve.
|
||||
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"
|
||||
|
||||
# Unknown/unexpected state (paused, etc.)
|
||||
@@ -287,9 +292,8 @@ class HealthMonitor:
|
||||
return
|
||||
# Skip "not_found" errors for containers that were never running
|
||||
# (e.g. still starting, or intentionally stopped/deleted).
|
||||
if (
|
||||
snapshot.container_status == "not_found"
|
||||
and (previous is None or previous.container_status != "running")
|
||||
if snapshot.container_status == "not_found" and (
|
||||
previous is None or previous.container_status != "running"
|
||||
):
|
||||
return
|
||||
category = "instance"
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
dir: apps/api/src/services/shared
|
||||
|
||||
## role
|
||||
Provides reusable, cross-cutting infrastructure services for security, I/O, container operations, and workspace management used throughout the API layer.
|
||||
Provides common, cross-cutting backend services used by multiple API components for security, infrastructure, and user-facing operations.
|
||||
## parent
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
## 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
|
||||
- __init__.py
|
||||
- correlation.py
|
||||
@@ -24,5 +26,7 @@ map: apps/api/src/services/shared/.pi-map.md
|
||||
## workflows
|
||||
- change shared behavior
|
||||
read: __init__.py, correlation.py, file_service.py
|
||||
- explore shared subdirectories
|
||||
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: apps/api/src/services/shared
|
||||
index: apps/api/src/services/shared/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides reusable, cross-cutting infrastructure services for security, I/O, container operations, and workspace management used throughout the API layer.
|
||||
Provides common, cross-cutting backend services used by multiple API components for security, infrastructure, and user-facing operations.
|
||||
## 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
|
||||
- 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 reusable, cross-cutting infrastructure services for security, I/O, cont
|
||||
- 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
|
||||
- tunnel.py | Re-exports Docker tunnel functions from a nested module for backward compatibility. | dep: src.services.docker.tunnel
|
||||
- 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
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
Modular utility services following singleton and async patterns, with Docker/container integration, filesystem sandboxing, context-based request tracing, and strict resource ownership isolation.
|
||||
## tags
|
||||
error, workspace, call:self., get, key, src, call:session.execute, call:ssh
|
||||
error, call:self., get, workspace, src, key, call:str, call:session.execute
|
||||
## symbols
|
||||
- CorrelationIdMiddleware
|
||||
- FileEntry
|
||||
@@ -31,5 +31,7 @@ error, workspace, call:self., get, key, src, call:session.execute, call:ssh
|
||||
## workflows
|
||||
- change shared behavior
|
||||
read: __init__.py, correlation.py, file_service.py
|
||||
- explore shared subdirectories
|
||||
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy import select
|
||||
from src.models import Workspace
|
||||
from src.services.git.git_service import GitService
|
||||
from src.services.shared.ssh_keys import _get_fernet
|
||||
from src.utils.git_url_parser import extract_base_repo_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -47,9 +48,32 @@ class WorkspaceManager:
|
||||
|
||||
BASE_PATH = "/data/working-copies"
|
||||
|
||||
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
||||
"""Return the filesystem path for a workspace."""
|
||||
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
||||
@staticmethod
|
||||
def _repo_directory_name(repo: "GitRepository") -> str:
|
||||
"""Return the directory name git would create for a standard clone.
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -74,24 +98,32 @@ class WorkspaceManager:
|
||||
Raises:
|
||||
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:
|
||||
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
|
||||
if os.path.exists(path):
|
||||
logger.warning("Removing stale workspace directory: %s", path)
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.exists(expected_path):
|
||||
logger.warning("Removing stale workspace directory: %s", expected_path)
|
||||
shutil.rmtree(expected_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
|
||||
ssh_key = None
|
||||
@@ -108,19 +140,33 @@ class WorkspaceManager:
|
||||
ssh_key_obj.private_key_encrypted.encode()
|
||||
).decode()
|
||||
|
||||
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
|
||||
self._make_world_writable(path)
|
||||
try:
|
||||
await GitService.clone(
|
||||
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(
|
||||
id=workspace_id,
|
||||
name=name,
|
||||
repo_id=repo.id,
|
||||
user_id=user_id,
|
||||
branch=branch,
|
||||
path=path,
|
||||
path=expected_path,
|
||||
status="ready",
|
||||
last_sync_at=datetime.now(),
|
||||
)
|
||||
logger.info("Workspace created: %s", workspace.id)
|
||||
logger.info("Workspace created: %s at %s", workspace.id, workspace.path)
|
||||
return workspace
|
||||
|
||||
async def delete(
|
||||
@@ -375,24 +421,30 @@ class WorkspaceManager:
|
||||
if not os.path.exists(clone_path):
|
||||
raise RuntimeError(f"Clone path not found: {clone_path}")
|
||||
|
||||
path = self._workspace_path(repo.id, name)
|
||||
parent = os.path.dirname(path)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
workspace_id = uuid.uuid4()
|
||||
parent_path = os.path.join(self.BASE_PATH, str(workspace_id))
|
||||
repo_dir_name = self._repo_directory_name(repo)
|
||||
target_path = os.path.join(parent_path, repo_dir_name)
|
||||
|
||||
os.makedirs(parent_path, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(parent, 0o777)
|
||||
os.chmod(parent_path, 0o777)
|
||||
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.exists(target_path):
|
||||
shutil.rmtree(target_path, ignore_errors=True)
|
||||
|
||||
shutil.move(clone_path, path)
|
||||
self._make_world_writable(path)
|
||||
# Move the existing clone into the repo-named subdirectory so the
|
||||
# workspace path matches the natural git clone layout.
|
||||
shutil.move(clone_path, target_path)
|
||||
self._make_world_writable(target_path)
|
||||
|
||||
workspace = Workspace(
|
||||
id=workspace_id,
|
||||
name=name,
|
||||
repo_id=repo.id,
|
||||
user_id=instance.owner_id,
|
||||
branch=instance.branch or "main",
|
||||
path=path,
|
||||
path=target_path,
|
||||
status="ready",
|
||||
last_sync_at=datetime.now(),
|
||||
)
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
dir: apps/api/src/services/tool
|
||||
|
||||
## role
|
||||
Provides backend infrastructure for provisioning and managing isolated development tool instances with their dependencies and network access.
|
||||
Provides infrastructure orchestration for isolated tool instances, managing their complete lifecycle from provisioning to teardown.
|
||||
## parent
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
## 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
|
||||
- instance_service.py
|
||||
## links
|
||||
@@ -16,5 +18,7 @@ map: apps/api/src/services/tool/.pi-map.md
|
||||
## workflows
|
||||
- change tool behavior
|
||||
read: instance_service.py
|
||||
- explore tool subdirectories
|
||||
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,16 +5,24 @@ import contextlib
|
||||
import glob as glob_module
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import ConfigProfile, GitRepository, Project, SSHKey, ToolInstance, ToolType
|
||||
from src.models import (
|
||||
ConfigProfile,
|
||||
GitRepository,
|
||||
Project,
|
||||
SSHKey,
|
||||
ToolInstance,
|
||||
ToolType,
|
||||
Workspace,
|
||||
)
|
||||
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
|
||||
from src.services.git.clone import check_dirty_state, clone_repository
|
||||
from src.services.config.config_profile_resolver import (
|
||||
@@ -64,15 +72,40 @@ from src.services.shared.permission_fixer import (
|
||||
apply_ssh_permissions,
|
||||
)
|
||||
from src.services.shared.readiness_probe import execute_probe
|
||||
from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
|
||||
from src.services.shared.ssh_keys import prepare_ssh_key_files
|
||||
from src.services.instance.event_bus import InstanceEventBus
|
||||
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
|
||||
from src.auth.dependencies import _get_owned_project, _get_user
|
||||
from src.utils.git_url_parser import extract_base_repo_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_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:
|
||||
"""Recursively chown a path, suppressing permission errors."""
|
||||
try:
|
||||
@@ -107,6 +140,79 @@ def _chown_staged_mounts(
|
||||
_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(
|
||||
session: AsyncSession,
|
||||
resolved: ResolvedProfile,
|
||||
@@ -139,7 +245,7 @@ async def resolve_git_mounts(
|
||||
if isinstance(result, Exception):
|
||||
logger.warning("Git mount failed: %s", result)
|
||||
continue
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
volume_mounts.extend(result)
|
||||
|
||||
return volume_mounts
|
||||
@@ -172,7 +278,12 @@ def clone_git_repo(
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
|
||||
# Include the branch in the hash so different branches of the same repo
|
||||
# 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"
|
||||
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
||||
repo_path = os.path.join(clone_dir, "repo-clone")
|
||||
@@ -271,8 +382,20 @@ def resolve_git_mount_mappings(
|
||||
# Single match: mount directly to target_path
|
||||
mount_target = final_target
|
||||
else:
|
||||
# Multiple matches: append relative path to target
|
||||
rel_path = os.path.relpath(matched_path, repo_path)
|
||||
# Multiple matches: append the path relative to the glob's base
|
||||
# directory so `packages/*` → `/app/packages` yields
|
||||
# `/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)
|
||||
|
||||
volume_mounts.append(
|
||||
@@ -325,7 +448,14 @@ async def resolve_single_git_mount(
|
||||
repo_path = await asyncio.to_thread(
|
||||
clone_git_repo, remote_url, branch, instance_dir
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Git mount clone failed for %s (branch=%s): %s",
|
||||
remote_url,
|
||||
branch,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
# Resolve all mappings from the cloned repo
|
||||
@@ -777,7 +907,6 @@ def ensure_backend_network_in_compose(compose_path: str) -> None:
|
||||
logger.info("Injected backend network '%s' into compose file", network_name)
|
||||
|
||||
|
||||
|
||||
async def prepare_manifest_instance(
|
||||
session: AsyncSession,
|
||||
instance: ToolInstance,
|
||||
@@ -883,12 +1012,27 @@ async def prepare_manifest_instance(
|
||||
# The actual resolution happens in resolve_git_mounts; we store placeholder
|
||||
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 = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance.name.lower(),
|
||||
"INSTANCE_DIR": instance_dir,
|
||||
"WORKSPACE_PATH": repo_path,
|
||||
"REPO_PATH": repo_path,
|
||||
"REPO_NAME": repo_name,
|
||||
"WORKSPACE_NAME": repo_name,
|
||||
"SSH_PATH": ssh_path,
|
||||
"TOOL_PORT": instance.port or 0,
|
||||
"EXTRA_ENV": env_vars,
|
||||
@@ -918,8 +1062,6 @@ async def prepare_manifest_instance(
|
||||
return image_tag, compose_content, manifest, home_dir
|
||||
|
||||
|
||||
|
||||
|
||||
async def create_tool_instance(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
@@ -1043,15 +1185,29 @@ async def create_tool_instance(
|
||||
else ""
|
||||
)
|
||||
|
||||
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"""
|
||||
home_dir = tool_type.home_directory or "/home/user"
|
||||
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)
|
||||
|
||||
elif tool_type.definition_type == "manifest":
|
||||
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:
|
||||
raise RuntimeError("Manifest definition not found for this tool type")
|
||||
|
||||
@@ -1061,20 +1217,21 @@ async def create_tool_instance(
|
||||
ToolDefinitionManifest, manifest_def.base_definition_id
|
||||
)
|
||||
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)
|
||||
|
||||
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
|
||||
# deprecated alias for backward compatibility with older templates.
|
||||
mount_name = _get_repository_mount_name(repo, workspace)
|
||||
variables = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance_name.lower(),
|
||||
"INSTANCE_DIR": instance_dir,
|
||||
"WORKSPACE_PATH": repo_path,
|
||||
"REPO_PATH": repo_path,
|
||||
"REPO_NAME": mount_name,
|
||||
"WORKSPACE_NAME": mount_name,
|
||||
"SSH_PATH": "",
|
||||
"TOOL_PORT": tool_port,
|
||||
"EXTRA_ENV": {},
|
||||
@@ -1095,10 +1252,10 @@ async def create_tool_instance(
|
||||
"TOOL_PORT": tool_port,
|
||||
"USER_ID": str(user_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)
|
||||
|
||||
@@ -1182,7 +1339,7 @@ async def start_tool_instance(
|
||||
|
||||
# Fetch tool type early to determine home directory and container user
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
home_dir = "/root"
|
||||
home_dir = tool_type.home_directory if tool_type and tool_type.home_directory else "/root"
|
||||
container_uid = 0
|
||||
container_gid = 0
|
||||
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
||||
@@ -1200,16 +1357,22 @@ async def start_tool_instance(
|
||||
deep_merge(dict(base_def.manifest), 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")
|
||||
if user_cfg:
|
||||
container_uid = user_cfg.get("uid", 0)
|
||||
container_gid = user_cfg.get("gid", 0)
|
||||
logger.debug(
|
||||
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s",
|
||||
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s, working_directory=%s",
|
||||
instance.id,
|
||||
container_uid,
|
||||
container_gid,
|
||||
home_dir,
|
||||
working_directory,
|
||||
)
|
||||
|
||||
# Apply selected config profile if any
|
||||
@@ -1222,27 +1385,37 @@ async def start_tool_instance(
|
||||
profile_env, profile_files, profile_mounts, profile_hints = (
|
||||
apply_resolved_profile(instance_dir, resolved, home_dir)
|
||||
)
|
||||
env_vars.update(profile_env)
|
||||
config_files.update(profile_files)
|
||||
extra_volumes.extend(profile_mounts)
|
||||
git_mount_volumes = await resolve_git_mounts(
|
||||
session, resolved, instance_dir, working_directory, home_dir
|
||||
)
|
||||
extra_volumes.extend(git_mount_volumes)
|
||||
# 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 = profile_hints["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)
|
||||
config_files.update(profile_files)
|
||||
git_mount_volumes = await resolve_git_mounts(
|
||||
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)
|
||||
logger.debug(
|
||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
|
||||
resolved.profile_name,
|
||||
instance.id,
|
||||
len(profile_env),
|
||||
len(profile_files),
|
||||
len(profile_mounts),
|
||||
len(git_mount_volumes),
|
||||
len(profile_mounts) - len(stacked_profile_mounts),
|
||||
)
|
||||
except ConfigProfileCycleError as exc:
|
||||
logger.error(
|
||||
@@ -1665,6 +1838,41 @@ async def start_tool_instance(
|
||||
|
||||
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"
|
||||
await session.commit()
|
||||
await publish_lifecycle_event(
|
||||
@@ -2093,7 +2301,9 @@ async def stop_tool_instance(
|
||||
try:
|
||||
stop_tunnel(instance.name)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||
logger.warning(
|
||||
"Failed to stop tunnel for instance %s: %s", instance.id, exc
|
||||
)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
execute_compose_command(instance.compose_path, "stop")
|
||||
@@ -2135,4 +2345,3 @@ async def rename_tool_instance(
|
||||
await session.commit()
|
||||
await session.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/tests
|
||||
|
||||
## role
|
||||
Provides shared test infrastructure and fixtures for the FastAPI API application.
|
||||
Provides shared test infrastructure and fixtures for the API application's test suite.
|
||||
## parent
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: apps/api/tests
|
||||
index: apps/api/tests/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides shared test infrastructure and fixtures for the FastAPI API application.
|
||||
Provides shared test infrastructure and fixtures for the API application's test suite.
|
||||
## 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
|
||||
## arch
|
||||
Pytest fixture-based testing architecture using async SQLite in-memory database, dependency injection overrides, and async HTTP client setup for isolated integration tests.
|
||||
Pytest fixture-based testing architecture with async SQLite test database, dependency injection overrides, and authenticated client factories using FastAPI's TestClient.
|
||||
## tags
|
||||
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/tests/integration
|
||||
|
||||
## role
|
||||
Integration test suite for the API backend, covering authentication, workspaces, projects, notifications, configuration, and database models with real PostgreSQL and git repositories.
|
||||
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.
|
||||
## parent
|
||||
index: apps/api/tests/.pi-map.index.md
|
||||
map: apps/api/tests/.pi-map.md
|
||||
@@ -21,6 +21,7 @@ map: apps/api/tests/.pi-map.md
|
||||
- test_projects_api.py
|
||||
- test_seed.py
|
||||
- test_ssh_keys_api.py
|
||||
- test_tool_instance_lifecycle.py
|
||||
- test_tool_types_api.py
|
||||
- test_tool_types_api_extended.py
|
||||
- test_users_api.py
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: apps/api/tests/integration
|
||||
index: apps/api/tests/integration/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Integration test suite for the API backend, covering authentication, workspaces, projects, notifications, configuration, and database models with real PostgreSQL and git repositories.
|
||||
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.
|
||||
## files
|
||||
- __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
|
||||
@@ -18,6 +18,7 @@ Integration test suite for the API backend, covering authentication, workspaces,
|
||||
- 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_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_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
|
||||
@@ -26,7 +27,7 @@ Integration test suite for the API backend, covering authentication, workspaces,
|
||||
- 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
|
||||
## arch
|
||||
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).
|
||||
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.
|
||||
## tags
|
||||
test, call:, call:db, call:authenticated, user, create, call:response.json, call:create
|
||||
## symbols
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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()
|
||||
@@ -2,14 +2,17 @@
|
||||
dir: apps/api/tests/unit
|
||||
|
||||
## role
|
||||
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
|
||||
Contains unit tests for the API application's core services, utilities, and infrastructure components.
|
||||
## parent
|
||||
index: apps/api/tests/.pi-map.index.md
|
||||
map: apps/api/tests/.pi-map.md
|
||||
## 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
|
||||
- __init__.py
|
||||
- test_alembic_migrations.py
|
||||
- test_config.py
|
||||
- test_config_profile_resolver.py
|
||||
- test_docker_build.py
|
||||
@@ -21,6 +24,7 @@ map: apps/api/tests/.pi-map.md
|
||||
- test_git_url_parser.py
|
||||
- test_health_monitor.py
|
||||
- test_home_path_expansion.py
|
||||
- test_instance_service.py
|
||||
- test_lifecycle_hooks.py
|
||||
- test_manifest_compiler.py
|
||||
- test_migration_metadata.py
|
||||
@@ -35,8 +39,10 @@ index: apps/api/tests/unit/.pi-map.index.md
|
||||
map: apps/api/tests/unit/.pi-map.md
|
||||
## workflows
|
||||
- change unit behavior
|
||||
read: __init__.py, test_config.py, test_config_profile_resolver.py
|
||||
read: __init__.py, test_alembic_migrations.py, test_config.py
|
||||
- change unit config
|
||||
read: test_config.py, test_config_profile_resolver.py
|
||||
- explore unit subdirectories
|
||||
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,9 +4,10 @@ dir: apps/api/tests/unit
|
||||
index: apps/api/tests/unit/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
|
||||
Contains unit tests for the API application's core services, utilities, and infrastructure components.
|
||||
## files
|
||||
- __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_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
|
||||
@@ -18,8 +19,9 @@ Contains comprehensive unit tests for the API backend services covering configur
|
||||
- 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_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_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_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_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_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
|
||||
@@ -28,9 +30,9 @@ Contains comprehensive unit tests for the API backend services covering configur
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
test, url, call:notification, git, call:, call:db, merge, src
|
||||
test, url, call:notification, git, call:, home, merge, call:db
|
||||
## symbols
|
||||
- TestMergeFunctions
|
||||
- TestResolveProfile
|
||||
@@ -42,8 +44,10 @@ test, url, call:notification, git, call:, call:db, merge, src
|
||||
- TestSortVolumesBySpecificity
|
||||
## workflows
|
||||
- change unit behavior
|
||||
read: __init__.py, test_config.py, test_config_profile_resolver.py
|
||||
read: __init__.py, test_alembic_migrations.py, test_config.py
|
||||
- change unit config
|
||||
read: test_config.py, test_config_profile_resolver.py
|
||||
- explore unit subdirectories
|
||||
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""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
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.build.manifest_compiler import compile_dockerfile
|
||||
from src.services.build.manifest_compiler import (
|
||||
compile_compose,
|
||||
compile_dockerfile,
|
||||
compile_entrypoint,
|
||||
get_manifest_home_dir,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -19,9 +24,16 @@ def test_compile_dockerfile_creates_config_dirs_for_user() -> None:
|
||||
assert "groupadd -g 1000 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/.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/.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
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -36,3 +48,314 @@ def test_compile_dockerfile_no_user_does_not_create_home() -> None:
|
||||
|
||||
assert "useradd" 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web
|
||||
|
||||
## role
|
||||
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
|
||||
Frontend web application providing the browser-based user interface for the project, built with React/Vite and served via nginx.
|
||||
## parent
|
||||
index: apps/.pi-map.index.md
|
||||
map: apps/.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ dir: apps/web
|
||||
index: apps/web/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
|
||||
Frontend web application providing the browser-based user interface for the project, built with React/Vite and served via nginx.
|
||||
## files
|
||||
- .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
|
||||
@@ -16,7 +16,7 @@ Frontend web application providing a React-based UI with code editing, terminal,
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
Modern SPA architecture using Vite for bundling, TypeScript for type safety, React for UI components, with containerized nginx deployment and environment-driven configuration.
|
||||
## tags
|
||||
react, eslint, vite, typescript, dom, application, nginx, web
|
||||
## symbols
|
||||
|
||||
Generated
+512
@@ -897,6 +897,24 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
@@ -914,6 +932,24 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
@@ -931,6 +967,24 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
@@ -6054,6 +6108,420 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
@@ -6081,6 +6549,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src
|
||||
|
||||
## role
|
||||
Frontend web application entry point and core infrastructure for a React-based collaborative development platform.
|
||||
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend.
|
||||
## parent
|
||||
index: apps/web/.pi-map.index.md
|
||||
map: apps/web/.pi-map.md
|
||||
|
||||
@@ -4,13 +4,13 @@ dir: apps/web/src
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Frontend web application entry point and core infrastructure for a React-based collaborative development platform.
|
||||
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend.
|
||||
## 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
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
pages, styles, css, router, react, session, dom, project
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components
|
||||
|
||||
## role
|
||||
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.
|
||||
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.
|
||||
## parent
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
map: apps/web/src/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: apps/web/src/components
|
||||
index: apps/web/src/components/.pi-map.index.md
|
||||
|
||||
## role
|
||||
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.
|
||||
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.
|
||||
## 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
|
||||
- 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 components and utilities for rendering the appl
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
toast, icon, react, state, code, loading, event, editor
|
||||
## symbols
|
||||
|
||||
@@ -46,9 +46,13 @@ const SessionItem = ({ session }: { session: Session }) => {
|
||||
? `/instances/${session.id}/terminal`
|
||||
: `/projects/${session.project_id}`;
|
||||
|
||||
const tooltipParts = [session.display_name, session.project_name];
|
||||
if (session.workspace_name) tooltipParts.push(session.workspace_name);
|
||||
else if (session.repository_name) tooltipParts.push(session.repository_name);
|
||||
const contextName = session.workspace_name || session.repository_name;
|
||||
const tooltipParts = [
|
||||
session.display_name,
|
||||
session.tool_type_name,
|
||||
session.project_name,
|
||||
];
|
||||
if (contextName) tooltipParts.push(contextName);
|
||||
tooltipParts.push(`(${session.status})`);
|
||||
|
||||
return (
|
||||
@@ -57,13 +61,20 @@ const SessionItem = ({ session }: { session: Session }) => {
|
||||
target={`session-${session.id}`}
|
||||
rel="noreferrer"
|
||||
className="nav-item session-item"
|
||||
title={tooltipParts.join(" ")}
|
||||
title={tooltipParts.join(" · ")}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className="session-name">
|
||||
{session.display_name}
|
||||
<span className="session-tool">{session.tool_type_name}</span>
|
||||
<span className="session-icon-wrap">
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
</span>
|
||||
<span className="session-meta">
|
||||
{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>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components/features
|
||||
|
||||
## role
|
||||
Contains reusable React components that implement specific user-facing features and functionality across the web application.
|
||||
Contains reusable React components that implement specific product features and user-facing functionality across the web application.
|
||||
## parent
|
||||
index: apps/web/src/components/.pi-map.index.md
|
||||
map: apps/web/src/components/.pi-map.md
|
||||
@@ -19,6 +19,9 @@ map: apps/web/src/components/.pi-map.md
|
||||
- apps/web/src/components/features/notification
|
||||
index: apps/web/src/components/features/notification/.pi-map.index.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
|
||||
index: apps/web/src/components/features/project/.pi-map.index.md
|
||||
map: apps/web/src/components/features/project/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
|
||||
index: apps/web/src/components/features/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains reusable React components that implement specific user-facing features and functionality across the web application.
|
||||
Contains reusable React components that implement specific product features and user-facing functionality across the web application.
|
||||
## files
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components/features/config-profiles
|
||||
|
||||
## role
|
||||
Provides a complete UI subsystem for managing configuration profiles with desktop and mobile views, supporting CRUD operations, profile relationships, and advanced configuration options.
|
||||
Provides UI components for managing Docker configuration profiles including creation, editing, listing, and mobile-responsive views.
|
||||
## parent
|
||||
index: apps/web/src/components/features/.pi-map.index.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
|
||||
|
||||
## role
|
||||
Provides a complete UI subsystem for managing configuration profiles with desktop and mobile views, supporting CRUD operations, profile relationships, and advanced configuration options.
|
||||
Provides UI components for managing Docker configuration profiles including creation, editing, listing, and mobile-responsive views.
|
||||
## 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
|
||||
- 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 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
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
Feature-based component composition with state-driven panels (list/detail/edit/preview), form-based editing, and responsive layout adaptation for mobile/desktop.
|
||||
## tags
|
||||
config, mobile, profiles, profile, view, editor, icon, list
|
||||
mobile, config, view, profiles, editor, profile, list, icon
|
||||
## symbols
|
||||
- ConfigProfileEditorPanel
|
||||
- ConfigProfileListSidebar
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components/features/mobile
|
||||
|
||||
## role
|
||||
Provides mobile-optimized UI components for core application features including navigation, data views, forms, modals, and specialized terminal interfaces.
|
||||
Provides a complete set of mobile-optimized UI components for CRUD operations, navigation, and terminal interfaces in a responsive web application.
|
||||
## parent
|
||||
index: apps/web/src/components/features/.pi-map.index.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
|
||||
|
||||
## role
|
||||
Provides mobile-optimized UI components for core application features including navigation, data views, forms, modals, and specialized terminal interfaces.
|
||||
Provides a complete set of mobile-optimized UI components for CRUD operations, navigation, and terminal interfaces in a responsive web application.
|
||||
## 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-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 data with configurable field types and save/cancel actions. | exp: MobileEditView | dep: react
|
||||
- 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-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-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 | 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 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-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-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-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-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
|
||||
## arch
|
||||
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.
|
||||
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).
|
||||
## tags
|
||||
mobile, terminal, sheet, react, icon, view, header, renders
|
||||
mobile, terminal, sheet, react, icon, view, header, action
|
||||
## symbols
|
||||
- MobileActionSheet
|
||||
- MobilePageHeader
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MobileDetailViewProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onBack: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
|
||||
@@ -22,6 +23,7 @@ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onBack,
|
||||
children,
|
||||
}) => {
|
||||
const renderValue = (field: Field) => {
|
||||
if (field.value === null || field.value === undefined) {
|
||||
@@ -90,6 +92,7 @@ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ interface MobileEditViewProps {
|
||||
fields?: FormField[];
|
||||
onSave: (data: Record<string, string | number | boolean>) => void;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => void;
|
||||
deleteLabel?: string;
|
||||
isSaving?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
@@ -25,6 +27,8 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
|
||||
fields,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
deleteLabel = "Delete",
|
||||
isSaving = false,
|
||||
children,
|
||||
}) => {
|
||||
@@ -59,14 +63,7 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
|
||||
Cancel
|
||||
</button>
|
||||
<h1 className="mobile-edit-title">{title}</h1>
|
||||
<button
|
||||
className="mobile-edit-save"
|
||||
onClick={() => onSave(formData)}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<div style={{ width: "4rem" }} />
|
||||
</header>
|
||||
|
||||
<form className="mobile-edit-form" onSubmit={handleSubmit}>
|
||||
@@ -156,6 +153,27 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ interface MobileListViewProps {
|
||||
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||
items,
|
||||
onItemClick,
|
||||
onItemDelete,
|
||||
onItemDuplicate,
|
||||
emptyMessage = "No items found",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearch,
|
||||
@@ -73,11 +75,38 @@ export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="mobile-list-item-actions"
|
||||
style={{ transform: "rotate(180deg)" }}
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<div className="mobile-list-item-actions">
|
||||
{onItemDuplicate && (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-list-item-action mobile-list-item-action-duplicate"
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -27,10 +27,17 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
||||
null,
|
||||
);
|
||||
const [terminalRef, setTerminalRef] = useState<{
|
||||
sendData: (data: string) => void;
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
connectionStatus:
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting";
|
||||
focusInput: () => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
} | null>(null);
|
||||
@@ -42,17 +49,33 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
}, [headerAutoHide]);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
|
||||
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
|
||||
(
|
||||
_sessionId: string | undefined,
|
||||
sendData: (data: string) => void,
|
||||
connectionStatus:
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting",
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => {
|
||||
setTerminalRef({
|
||||
sendData,
|
||||
connectionStatus,
|
||||
focusInput,
|
||||
changeFontSize,
|
||||
});
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSendKey = useCallback(
|
||||
(data: string) => {
|
||||
terminalRef?.sendData(data);
|
||||
},
|
||||
[terminalRef]
|
||||
[terminalRef],
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
-
|
||||
@@ -0,0 +1,119 @@
|
||||
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,6 +1,9 @@
|
||||
import React from "react";
|
||||
import { TerminalComponent, type TerminalRef } from "./terminal";
|
||||
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "./terminal-session-tabs";
|
||||
import type { TerminalSession } from "../../../api/terminal";
|
||||
|
||||
interface Props {
|
||||
@@ -8,7 +11,9 @@ interface Props {
|
||||
sessions: TerminalSession[];
|
||||
sessionInfos: TerminalSessionInfo[];
|
||||
activeSessionId: string;
|
||||
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
|
||||
terminalRefs: React.MutableRefObject<
|
||||
Record<string, React.RefObject<TerminalRef>>
|
||||
>;
|
||||
isFullscreen: boolean;
|
||||
status: string;
|
||||
error: string | null;
|
||||
@@ -26,6 +31,7 @@ interface Props {
|
||||
onHideResetConfirm: () => void;
|
||||
onReset: () => void;
|
||||
onTerminalReady: (
|
||||
sessionId: string | undefined,
|
||||
sendData: (data: string) => void,
|
||||
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||
focusInput: () => void,
|
||||
@@ -64,7 +70,11 @@ export const DesktopTerminalView: React.FC<Props> = ({
|
||||
>
|
||||
{!isFullscreen && (
|
||||
<div className="terminal-page-header">
|
||||
<button className="secondary-button" onClick={onNavigateBack} type="button">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onNavigateBack}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
@@ -172,21 +182,25 @@ export const DesktopTerminalView: React.FC<Props> = ({
|
||||
)}
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions
|
||||
.filter((session) => session.id === activeSessionId)
|
||||
.map((session) => (
|
||||
<div key={session.id} className="terminal-instance active">
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => onClose(session.id)}
|
||||
isMobile={false}
|
||||
showControls={!isFullscreen}
|
||||
onTerminalReady={onTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
|
||||
style={{
|
||||
display: session.id === activeSessionId ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => onClose(session.id)}
|
||||
isMobile={false}
|
||||
showControls={!isFullscreen}
|
||||
onTerminalReady={onTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from "react";
|
||||
import { TerminalComponent, type TerminalRef } from "./terminal";
|
||||
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "./terminal-session-tabs";
|
||||
import { Icon } from "../../icon";
|
||||
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||
@@ -12,7 +15,9 @@ interface Props {
|
||||
sessions: TerminalSession[];
|
||||
sessionInfos: TerminalSessionInfo[];
|
||||
activeSessionId: string;
|
||||
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
|
||||
terminalRefs: React.MutableRefObject<
|
||||
Record<string, React.RefObject<TerminalRef>>
|
||||
>;
|
||||
status: string;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
@@ -29,6 +34,7 @@ interface Props {
|
||||
onCreate: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onTerminalReady: (
|
||||
sessionId: string | undefined,
|
||||
sendData: (data: string) => void,
|
||||
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||
focusInput: () => void,
|
||||
@@ -73,25 +79,53 @@ export const MobileTerminalView: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<section className="terminal-page mobile">
|
||||
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mobile-terminal-toolbar">
|
||||
<div className="mobile-terminal-toolbar-left">
|
||||
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={onNavigateBack}
|
||||
type="button"
|
||||
aria-label="Back"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-terminal-toolbar-center">
|
||||
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
|
||||
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
|
||||
<span className="mobile-terminal-title">
|
||||
{activeSession?.name || "Terminal"}
|
||||
</span>
|
||||
<span
|
||||
className={`mobile-terminal-status status-dot ${status}`}
|
||||
aria-label={`Connection status: ${status}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mobile-terminal-toolbar-right">
|
||||
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => onFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||
</button>
|
||||
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => onFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||
</button>
|
||||
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={onNavigateBack}
|
||||
type="button"
|
||||
aria-label="Exit terminal"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -115,23 +149,27 @@ export const MobileTerminalView: React.FC<Props> = ({
|
||||
onClick={onToggleHeader}
|
||||
>
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions
|
||||
.filter((session) => session.id === activeSessionId)
|
||||
.map((session) => (
|
||||
<div key={session.id} className="terminal-instance active">
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => onClose(session.id)}
|
||||
isMobile={true}
|
||||
showControls={false}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={onModifierChange}
|
||||
onTerminalReady={onTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
|
||||
style={{
|
||||
display: session.id === activeSessionId ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => onClose(session.id)}
|
||||
isMobile={true}
|
||||
showControls={false}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={onModifierChange}
|
||||
onTerminalReady={onTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<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
|
||||
|
||||
## role
|
||||
Provides a complete UI for managing custom tool types in a "Tool Workshop" interface with list, edit, and mobile-responsive views.
|
||||
Provides a complete UI for managing custom tool types (compose, dockerfile, manifest) in a workshop interface with list, edit, and mobile-responsive views.
|
||||
## parent
|
||||
index: apps/web/src/components/features/.pi-map.index.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
|
||||
|
||||
## role
|
||||
Provides a complete UI for managing custom tool types in a "Tool Workshop" interface with list, edit, and mobile-responsive views.
|
||||
Provides a complete UI for managing custom tool types (compose, dockerfile, manifest) in a workshop interface with list, edit, and mobile-responsive views.
|
||||
## 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
|
||||
- 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 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
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
Feature-based component composition with split-pane layout (sidebar + editor panel), responsive mobile adaptation, and CRUD operations for tool type definitions.
|
||||
## tags
|
||||
tool, mobile, type, view, types, list, panel, editor
|
||||
tool, mobile, view, type, types, editor, list, workshop
|
||||
## symbols
|
||||
- ToolTypeFormState
|
||||
- ToolTypeEditorPanel
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { MobileListView } from "../mobile/mobile-list-view";
|
||||
import { MobileDetailView } from "../mobile/mobile-detail-view";
|
||||
import { MobileEditView } from "../mobile/mobile-edit-view";
|
||||
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 { ToolTypeFormState } from "./ToolTypeEditorPanel";
|
||||
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
|
||||
|
||||
export type MobileView = "list" | "detail" | "edit";
|
||||
|
||||
@@ -13,13 +18,18 @@ interface ToolWorkshopMobileViewProps {
|
||||
mobileView: MobileView;
|
||||
isCreating: boolean;
|
||||
toolTypeForm: ToolTypeFormState;
|
||||
manifestData: Record<string, unknown> | null;
|
||||
manifestDefinitionId: string | null;
|
||||
baseDefinitions: ToolDefinitionManifest[];
|
||||
toolTypeError: string | null;
|
||||
toolTypeDirty: boolean;
|
||||
onViewChange: (view: MobileView) => void;
|
||||
onSelect: (toolType: ToolType) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
|
||||
onSubmit: () => void;
|
||||
onManifestChange: (manifest: Record<string, unknown> | null) => void;
|
||||
onSubmit: (e?: React.FormEvent) => Promise<boolean>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -29,15 +39,41 @@ export const ToolWorkshopMobileView = ({
|
||||
mobileView,
|
||||
isCreating,
|
||||
toolTypeForm,
|
||||
manifestData,
|
||||
manifestDefinitionId,
|
||||
baseDefinitions,
|
||||
toolTypeError,
|
||||
toolTypeDirty,
|
||||
onViewChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onFormChange,
|
||||
onManifestChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: 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") {
|
||||
return (
|
||||
<div className="mobile-page">
|
||||
@@ -49,15 +85,16 @@ export const ToolWorkshopMobileView = ({
|
||||
items={toolTypes.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.display_name,
|
||||
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||
subtitle: `${t.category || "Uncategorized"} · ${t.definition_type} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||
}))}
|
||||
onItemClick={(id) => {
|
||||
const toolType = toolTypes.find((t) => t.id === id);
|
||||
if (toolType) {
|
||||
onSelect(toolType);
|
||||
onViewChange("detail");
|
||||
onViewChange("edit");
|
||||
}
|
||||
}}
|
||||
onItemDelete={(id) => onDelete(id)}
|
||||
emptyMessage="No tool types yet"
|
||||
/>
|
||||
<MobileFAB
|
||||
@@ -71,64 +108,70 @@ export const ToolWorkshopMobileView = ({
|
||||
}
|
||||
|
||||
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 (
|
||||
<MobileDetailView
|
||||
title={selectedToolType.display_name}
|
||||
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
|
||||
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",
|
||||
},
|
||||
]}
|
||||
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type}`}
|
||||
fields={fields}
|
||||
onEdit={() => {
|
||||
onViewChange("edit");
|
||||
}}
|
||||
onDelete={() => {
|
||||
void onDelete(selectedToolType.id);
|
||||
onViewChange("list");
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
onBack={() => {
|
||||
onViewChange("list");
|
||||
}}
|
||||
@@ -137,18 +180,41 @@ export const ToolWorkshopMobileView = ({
|
||||
}
|
||||
|
||||
if (mobileView === "edit") {
|
||||
const templateValue =
|
||||
toolTypeForm.definition_type === "compose"
|
||||
? toolTypeForm.compose_template
|
||||
: toolTypeForm.dockerfile_template;
|
||||
|
||||
return (
|
||||
<MobileEditView
|
||||
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
|
||||
onCancel={onCancel}
|
||||
onSave={() => {
|
||||
onSubmit();
|
||||
if (!toolTypeError) {
|
||||
onViewChange("list");
|
||||
}
|
||||
}}
|
||||
isSaving={false}
|
||||
onSave={handleSave}
|
||||
onDelete={isCreating ? undefined : handleDelete}
|
||||
deleteLabel="Delete Tool Type"
|
||||
isSaving={isSaving}
|
||||
>
|
||||
<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">
|
||||
<label className="mobile-form-label">Name *</label>
|
||||
<input
|
||||
@@ -157,8 +223,11 @@ export const ToolWorkshopMobileView = ({
|
||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||
className="mobile-form-input"
|
||||
placeholder="e.g., my-tool"
|
||||
disabled={!isCreating}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Display Name *</label>
|
||||
<input
|
||||
@@ -167,8 +236,10 @@ export const ToolWorkshopMobileView = ({
|
||||
onChange={(e) => onFormChange({ display_name: e.target.value })}
|
||||
className="mobile-form-input"
|
||||
placeholder="e.g., My Tool"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Description</label>
|
||||
<textarea
|
||||
@@ -179,6 +250,7 @@ export const ToolWorkshopMobileView = ({
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Category</label>
|
||||
<input
|
||||
@@ -189,130 +261,179 @@ export const ToolWorkshopMobileView = ({
|
||||
placeholder="e.g., development"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Interface Type</label>
|
||||
<select
|
||||
value={toolTypeForm.interface_type}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as "web" | "terminal";
|
||||
onFormChange({
|
||||
interface_type: e.target.value as "web" | "terminal",
|
||||
})
|
||||
}
|
||||
interface_type: value,
|
||||
requires_port: value === "web",
|
||||
default_port: value === "web" ? toolTypeForm.default_port : "",
|
||||
});
|
||||
}}
|
||||
className="mobile-form-select"
|
||||
>
|
||||
<option value="web">Web</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Requires Port</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolTypeForm.requires_port}
|
||||
onChange={(e) => onFormChange({ requires_port: e.target.checked })}
|
||||
className="mobile-form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Default Port</label>
|
||||
<input
|
||||
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 className="mobile-form-group mobile-form-row">
|
||||
<label className="mobile-form-label mobile-form-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolTypeForm.requires_port}
|
||||
onChange={(e) =>
|
||||
onFormChange({ requires_port: e.target.checked })
|
||||
}
|
||||
className="mobile-form-checkbox"
|
||||
/>
|
||||
Requires Port
|
||||
</label>
|
||||
</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">
|
||||
<label className="mobile-form-label">Readiness Command</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toolTypeForm.readiness_command}
|
||||
onChange={(e) => onFormChange({ readiness_command: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onFormChange({ readiness_command: e.target.value })
|
||||
}
|
||||
className="mobile-form-input"
|
||||
placeholder="e.g., curl -f http://localhost:8080/health"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Readiness Timeout</label>
|
||||
<label className="mobile-form-label">Readiness Timeout (seconds)</label>
|
||||
<input
|
||||
type="text"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_timeout}
|
||||
onChange={(e) => onFormChange({ readiness_timeout: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onFormChange({ readiness_timeout: e.target.value })
|
||||
}
|
||||
className="mobile-form-input"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Readiness Interval</label>
|
||||
<label className="mobile-form-label">Readiness Interval (seconds)</label>
|
||||
<input
|
||||
type="text"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_interval}
|
||||
onChange={(e) => onFormChange({ readiness_interval: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onFormChange({ readiness_interval: e.target.value })
|
||||
}
|
||||
className="mobile-form-input"
|
||||
placeholder="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Required Variables</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toolTypeForm.required_variables}
|
||||
onChange={(e) => onFormChange({ required_variables: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onFormChange({ required_variables: e.target.value })
|
||||
}
|
||||
className="mobile-form-input"
|
||||
placeholder="VAR1, VAR2, VAR3"
|
||||
/>
|
||||
</div>
|
||||
{toolTypeForm.definition_type === "compose" && (
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Compose Template</label>
|
||||
<textarea
|
||||
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>
|
||||
|
||||
{toolTypeError && (
|
||||
<p className="mobile-form-error">
|
||||
<Icon name="warning" size="sm" /> {toolTypeError}
|
||||
</p>
|
||||
)}
|
||||
{toolTypeForm.definition_type === "dockerfile" && (
|
||||
<div className="mobile-form-group">
|
||||
<label className="mobile-form-label">Dockerfile Template</label>
|
||||
<textarea
|
||||
value={toolTypeForm.dockerfile_template}
|
||||
onChange={(e) =>
|
||||
onFormChange({ dockerfile_template: e.target.value })
|
||||
}
|
||||
className="mobile-form-textarea mobile-form-code"
|
||||
placeholder="FROM ubuntu:22.04"
|
||||
rows={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolTypeDirty && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button mobile-discard-button"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
)}
|
||||
</MobileEditView>
|
||||
);
|
||||
@@ -328,15 +449,16 @@ export const ToolWorkshopMobileView = ({
|
||||
items={toolTypes.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.display_name,
|
||||
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||
subtitle: `${t.category || "Uncategorized"} · ${t.definition_type} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||
}))}
|
||||
onItemClick={(id) => {
|
||||
const toolType = toolTypes.find((t) => t.id === id);
|
||||
if (toolType) {
|
||||
onSelect(toolType);
|
||||
onViewChange("detail");
|
||||
onViewChange("edit");
|
||||
}
|
||||
}}
|
||||
onItemDelete={(id) => onDelete(id)}
|
||||
emptyMessage="No tool types yet"
|
||||
/>
|
||||
<MobileFAB
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/hooks
|
||||
|
||||
## role
|
||||
A collection of custom React hooks providing reusable state management, API integration, and UI behavior logic for the web application.
|
||||
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.
|
||||
## parent
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
map: apps/web/src/.pi-map.md
|
||||
|
||||
@@ -4,15 +4,15 @@ dir: apps/web/src/hooks
|
||||
index: apps/web/src/hooks/.pi-map.index.md
|
||||
|
||||
## role
|
||||
A collection of custom React hooks providing reusable state management, API integration, and UI behavior logic for the web application.
|
||||
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.
|
||||
## 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-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 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 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 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-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-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.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-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 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-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-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.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 @@ A collection of custom React hooks providing reusable state management, API inte
|
||||
- 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-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 | 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-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-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-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 @@ A collection of custom React hooks providing reusable state management, API inte
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
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).
|
||||
## tags
|
||||
call:set, call:use, workspace, react, state, terminal, api, callback
|
||||
## symbols
|
||||
|
||||
@@ -193,7 +193,7 @@ export const useConfigProfiles = () => {
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
const handleSubmit = async (e?: React.FormEvent): Promise<boolean> => {
|
||||
e?.preventDefault();
|
||||
setError(null);
|
||||
setSaveStatus("saving");
|
||||
@@ -201,7 +201,7 @@ export const useConfigProfiles = () => {
|
||||
if (!formData.name?.trim()) {
|
||||
setError("Name is required");
|
||||
setSaveStatus("error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -224,9 +224,11 @@ export const useConfigProfiles = () => {
|
||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
||||
if (refreshed) populateForm(refreshed);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
setSaveStatus("error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,336 +10,359 @@ import type { TerminalSession } from "../api/terminal";
|
||||
import type { ModifierKey } from "./use-special-keys";
|
||||
|
||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as TerminalSessionInfo["status"],
|
||||
}));
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as TerminalSessionInfo["status"],
|
||||
}));
|
||||
|
||||
type TerminalStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting";
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting";
|
||||
|
||||
export const useTerminalPage = () => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
|
||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
||||
Record<string, TerminalStatus>
|
||||
>({});
|
||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||
const focusInputRef = useRef<(() => void) | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
||||
null,
|
||||
);
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
||||
Record<string, TerminalStatus>
|
||||
>({});
|
||||
const changeFontSizeRefs = useRef<
|
||||
Record<string, ((delta: number) => void) | null>
|
||||
>({});
|
||||
const sendDataRefs = useRef<Record<string, ((data: string) => void) | null>>(
|
||||
{},
|
||||
);
|
||||
const focusInputRefs = useRef<Record<string, (() => void) | null>>({});
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
||||
null,
|
||||
);
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
|
||||
const [instanceInfo, setInstanceInfo] = useState<{
|
||||
display_name: string;
|
||||
workspace_name?: string | null;
|
||||
tool_type_name: string;
|
||||
} | null>(null);
|
||||
const [instanceInfo, setInstanceInfo] = useState<{
|
||||
display_name: string;
|
||||
workspace_name?: string | null;
|
||||
tool_type_name: string;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
|
||||
// Fetch instance details for tab title
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
const load = async () => {
|
||||
try {
|
||||
const { getUserSessions } = await import("../api/sessions");
|
||||
const allSessions = await getUserSessions();
|
||||
const match = allSessions.find((s) => s.id === instanceId);
|
||||
if (match) {
|
||||
setInstanceInfo({
|
||||
display_name: match.display_name,
|
||||
workspace_name: match.workspace_name,
|
||||
tool_type_name: match.tool_type_name,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [instanceId]);
|
||||
// Fetch instance details for tab title
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
const load = async () => {
|
||||
try {
|
||||
const { getUserSessions } = await import("../api/sessions");
|
||||
const allSessions = await getUserSessions();
|
||||
const match = allSessions.find((s) => s.id === instanceId);
|
||||
if (match) {
|
||||
setInstanceInfo({
|
||||
display_name: match.display_name,
|
||||
workspace_name: match.workspace_name,
|
||||
tool_type_name: match.tool_type_name,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [instanceId]);
|
||||
|
||||
// Auto-create default session
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
// Auto-create default session
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
|
||||
// Update document title based on active terminal session
|
||||
useEffect(() => {
|
||||
if (!instanceId) {
|
||||
document.title = "Terminal";
|
||||
return;
|
||||
}
|
||||
const active = sessions.find((s) => s.id === activeSessionId);
|
||||
const baseName = instanceInfo
|
||||
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
|
||||
: `Instance ${instanceId.slice(0, 8)}`;
|
||||
if (sessions.length <= 1) {
|
||||
document.title = baseName;
|
||||
} else {
|
||||
const sessionName = active?.name ?? "Session";
|
||||
document.title = `${baseName} ${sessionName}`;
|
||||
}
|
||||
return () => {
|
||||
document.title = "Headquarter";
|
||||
};
|
||||
}, [instanceId, activeSessionId, sessions, instanceInfo]);
|
||||
// Update document title based on active terminal session
|
||||
useEffect(() => {
|
||||
if (!instanceId) {
|
||||
document.title = "Terminal";
|
||||
return;
|
||||
}
|
||||
const active = sessions.find((s) => s.id === activeSessionId);
|
||||
const baseName = instanceInfo
|
||||
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
|
||||
: `Instance ${instanceId.slice(0, 8)}`;
|
||||
if (sessions.length <= 1) {
|
||||
document.title = baseName;
|
||||
} else {
|
||||
const sessionName = active?.name ?? "Session";
|
||||
document.title = `${baseName} ${sessionName}`;
|
||||
}
|
||||
return () => {
|
||||
document.title = "Headquarter";
|
||||
};
|
||||
}, [instanceId, activeSessionId, sessions, instanceInfo]);
|
||||
|
||||
// Sync refs with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
const currentIds = new Set(sessions.map((s) => s.id));
|
||||
for (const id of Object.keys(terminalRefs.current)) {
|
||||
if (!currentIds.has(id)) {
|
||||
delete terminalRefs.current[id];
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
// Sync refs with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
const currentIds = new Set(sessions.map((s) => s.id));
|
||||
for (const id of Object.keys(terminalRefs.current)) {
|
||||
if (!currentIds.has(id)) {
|
||||
delete terminalRefs.current[id];
|
||||
delete sendDataRefs.current[id];
|
||||
delete focusInputRefs.current[id];
|
||||
delete changeFontSizeRefs.current[id];
|
||||
}
|
||||
}
|
||||
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
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
ref.current?.fit();
|
||||
ref.current?.focus();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
// Fit and focus active terminal
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
ref.current?.fit();
|
||||
ref.current?.focus();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
if (sessions.length < 5) {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}
|
||||
break;
|
||||
case "w":
|
||||
e.preventDefault();
|
||||
if (
|
||||
activeSessionId &&
|
||||
window.confirm("Close this terminal session?")
|
||||
) {
|
||||
void closeSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1)
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) void resetSession(activeSessionId);
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
}
|
||||
};
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
if (sessions.length < 5) {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}
|
||||
break;
|
||||
case "w":
|
||||
e.preventDefault();
|
||||
if (
|
||||
activeSessionId &&
|
||||
window.confirm("Close this terminal session?")
|
||||
) {
|
||||
void closeSession(activeSessionId);
|
||||
}
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1)
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) void resetSession(activeSessionId);
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
resetSession,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
resetSession,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// Keep screen awake
|
||||
useEffect(() => {
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
const requestWakeLock = async () => {
|
||||
try {
|
||||
if ("wakeLock" in navigator) {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void requestWakeLock();
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void requestWakeLock();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
wakeLock?.release().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
// Keep screen awake
|
||||
useEffect(() => {
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
const requestWakeLock = async () => {
|
||||
try {
|
||||
if ("wakeLock" in navigator) {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void requestWakeLock();
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void requestWakeLock();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
wakeLock?.release().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Lock page scroll on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
document.documentElement.classList.add("terminal-page-open");
|
||||
document.body.classList.add("terminal-page-open");
|
||||
return () => {
|
||||
document.documentElement.classList.remove("terminal-page-open");
|
||||
document.body.classList.remove("terminal-page-open");
|
||||
};
|
||||
}, [isMobile]);
|
||||
// Lock page scroll on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
document.documentElement.classList.add("terminal-page-open");
|
||||
document.body.classList.add("terminal-page-open");
|
||||
return () => {
|
||||
document.documentElement.classList.remove("terminal-page-open");
|
||||
document.body.classList.remove("terminal-page-open");
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
const handleFullscreenClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!isFullscreen) return;
|
||||
const target = e.target as Node;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
const content = current.querySelector(".terminal-page-content");
|
||||
const header = current.querySelector(".terminal-fullscreen-header");
|
||||
if (content?.contains(target) || header?.contains(target)) return;
|
||||
setIsFullscreen(false);
|
||||
},
|
||||
[isFullscreen],
|
||||
);
|
||||
const handleFullscreenClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!isFullscreen) return;
|
||||
const target = e.target as Node;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
const content = current.querySelector(".terminal-page-content");
|
||||
const header = current.querySelector(".terminal-fullscreen-header");
|
||||
if (content?.contains(target) || header?.contains(target)) return;
|
||||
setIsFullscreen(false);
|
||||
},
|
||||
[isFullscreen],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(sessionId: string) => setActiveSessionId(sessionId),
|
||||
[setActiveSessionId],
|
||||
);
|
||||
const handleSelect = useCallback(
|
||||
(sessionId: string) => setActiveSessionId(sessionId),
|
||||
[setActiveSessionId],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (sessionId: string) => closeSession(sessionId),
|
||||
[closeSession],
|
||||
);
|
||||
const handleClose = useCallback(
|
||||
async (sessionId: string) => closeSession(sessionId),
|
||||
[closeSession],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}, [createSession, sessions.length]);
|
||||
const handleCreate = useCallback(() => {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}, [createSession, sessions.length]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newName: string) => {
|
||||
void renameSession(sessionId, newName);
|
||||
},
|
||||
[renameSession],
|
||||
);
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newName: string) => {
|
||||
void renameSession(sessionId, newName);
|
||||
},
|
||||
[renameSession],
|
||||
);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(
|
||||
sendData: (data: string) => void,
|
||||
status: TerminalStatus,
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => {
|
||||
setTerminalStatuses((prev) => ({
|
||||
...prev,
|
||||
[activeSessionId ?? "default"]: status,
|
||||
}));
|
||||
sendDataRef.current = sendData;
|
||||
focusInputRef.current = focusInput;
|
||||
changeFontSizeRef.current = changeFontSize;
|
||||
},
|
||||
[activeSessionId],
|
||||
);
|
||||
const handleTerminalReady = useCallback(
|
||||
(
|
||||
sessionId: string | undefined,
|
||||
sendData: (data: string) => void,
|
||||
status: TerminalStatus,
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => {
|
||||
const key = sessionId ?? "default";
|
||||
setTerminalStatuses((prev) => ({
|
||||
...prev,
|
||||
[key]: status,
|
||||
}));
|
||||
sendDataRefs.current[key] = sendData;
|
||||
focusInputRefs.current[key] = focusInput;
|
||||
changeFontSizeRefs.current[key] = changeFontSize;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback((delta: number) => {
|
||||
changeFontSizeRef.current?.(delta);
|
||||
}, []);
|
||||
const handleFontSizeChange = useCallback(
|
||||
(delta: number) => {
|
||||
const key = activeSessionId ?? "default";
|
||||
changeFontSizeRefs.current[key]?.(delta);
|
||||
},
|
||||
[activeSessionId],
|
||||
);
|
||||
|
||||
const handleSendKey = useCallback((data: string) => {
|
||||
sendDataRef.current?.(data);
|
||||
}, []);
|
||||
const handleSendKey = useCallback(
|
||||
(data: string) => {
|
||||
const key = activeSessionId ?? "default";
|
||||
sendDataRefs.current[key]?.(data);
|
||||
},
|
||||
[activeSessionId],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
terminalRefs.current[activeSessionId].current?.reset();
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
const handleReset = useCallback(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
terminalRefs.current[activeSessionId].current?.reset();
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
navigate,
|
||||
isMobile,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
terminalRefs,
|
||||
headerAutoHide,
|
||||
terminalStatuses,
|
||||
sendDataRef,
|
||||
focusInputRef,
|
||||
changeFontSizeRef,
|
||||
showResetConfirm,
|
||||
setShowResetConfirm,
|
||||
showSpecialKeysPanel,
|
||||
setShowSpecialKeysPanel,
|
||||
activeModifier,
|
||||
setActiveModifier,
|
||||
isKeyboardOpen,
|
||||
keyboardHeight,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
loading,
|
||||
error,
|
||||
handleFullscreenClick,
|
||||
handleSelect,
|
||||
handleClose,
|
||||
handleCreate,
|
||||
handleRename,
|
||||
handleTerminalReady,
|
||||
handleFontSizeChange,
|
||||
handleSendKey,
|
||||
handleReset,
|
||||
sessionInfos: SESSIONS_TO_INFO(sessions),
|
||||
};
|
||||
return {
|
||||
instanceId,
|
||||
navigate,
|
||||
isMobile,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
terminalRefs,
|
||||
headerAutoHide,
|
||||
terminalStatuses,
|
||||
showResetConfirm,
|
||||
setShowResetConfirm,
|
||||
showSpecialKeysPanel,
|
||||
setShowSpecialKeysPanel,
|
||||
activeModifier,
|
||||
setActiveModifier,
|
||||
isKeyboardOpen,
|
||||
keyboardHeight,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
loading,
|
||||
error,
|
||||
handleFullscreenClick,
|
||||
handleSelect,
|
||||
handleClose,
|
||||
handleCreate,
|
||||
handleRename,
|
||||
handleTerminalReady,
|
||||
handleFontSizeChange,
|
||||
handleSendKey,
|
||||
handleReset,
|
||||
sessionInfos: SESSIONS_TO_INFO(sessions),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -151,13 +151,13 @@ export const useToolWorkshop = () => {
|
||||
resetToolTypeForm();
|
||||
};
|
||||
|
||||
const handleToolTypeSubmit = async (e?: React.FormEvent) => {
|
||||
const handleToolTypeSubmit = async (e?: React.FormEvent): Promise<boolean> => {
|
||||
e?.preventDefault();
|
||||
setToolTypeError(null);
|
||||
|
||||
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
||||
setToolTypeError("Name and display name are required");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -166,7 +166,7 @@ export const useToolWorkshop = () => {
|
||||
isNaN(Number(toolTypeForm.default_port)))
|
||||
) {
|
||||
setToolTypeError("Default port is required and must be a number");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toolTypeForm.definition_type !== "manifest") {
|
||||
@@ -179,13 +179,13 @@ export const useToolWorkshop = () => {
|
||||
setToolTypeError(
|
||||
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
} else if (!manifestData) {
|
||||
setToolTypeError(
|
||||
"Manifest data is required for manifest definition type",
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const variables = toolTypeForm.required_variables
|
||||
@@ -307,8 +307,10 @@ export const useToolWorkshop = () => {
|
||||
setToolTypeDirty(false);
|
||||
}
|
||||
await loadData();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setToolTypeError(extractErrorMessage(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/pages
|
||||
|
||||
## role
|
||||
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.
|
||||
Contains top-level page components that render the main UI views for the web application, each corresponding to a distinct route and feature area.
|
||||
## parent
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
map: apps/web/src/.pi-map.md
|
||||
|
||||
@@ -4,30 +4,30 @@ dir: apps/web/src/pages
|
||||
index: apps/web/src/pages/.pi-map.index.md
|
||||
|
||||
## role
|
||||
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.
|
||||
Contains top-level page components that render the main UI views for the web application, each corresponding to a distinct route and feature area.
|
||||
## files
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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 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
|
||||
- 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
|
||||
- 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.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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
## arch
|
||||
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.
|
||||
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.
|
||||
## tags
|
||||
page, components, workspace, features, react, mobile, settings, hooks
|
||||
page, components, workspace, features, mobile, react, hooks, settings
|
||||
## symbols
|
||||
- WorkspaceDetailPage
|
||||
- WorkspacesPage
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ConfigProfileListSidebar } from "../components/features/config-profiles
|
||||
import { ConfigProfileEditorPanel } from "../components/features/config-profiles/ConfigProfileEditorPanel";
|
||||
import { ConfigProfilesMobileView } from "../components/features/config-profiles/ConfigProfilesMobileView";
|
||||
|
||||
type MobileView = "list" | "detail" | "edit";
|
||||
type MobileView = "list" | "detail" | "edit" | "preview";
|
||||
|
||||
export const ConfigProfilesPage = () => {
|
||||
const isMobile = useMobileViewport();
|
||||
@@ -100,11 +100,18 @@ export const ConfigProfilesPage = () => {
|
||||
return (
|
||||
<ConfigProfilesMobileView
|
||||
profiles={profiles}
|
||||
projects={projects}
|
||||
toolTypes={toolTypes}
|
||||
selectedProfile={selectedProfile}
|
||||
mobileView={mobileView}
|
||||
isCreating={isCreating}
|
||||
formData={formData}
|
||||
includedProfileIds={includedProfileIds}
|
||||
availableProfiles={availableProfilesForInclude()}
|
||||
previewData={previewData}
|
||||
previewingId={previewingId}
|
||||
saveStatus={saveStatus}
|
||||
error={error}
|
||||
onViewChange={setMobileView}
|
||||
onSelect={handleSelectProfile}
|
||||
onCreate={handleCreateNew}
|
||||
@@ -112,6 +119,23 @@ export const ConfigProfilesPage = () => {
|
||||
onFormChange={updateFormField}
|
||||
onSubmit={handleSubmit}
|
||||
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,13 +3,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { ProfileMobileView } from "../components/features/profile/ProfileMobileView";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import type { UserProfile } from "../api/profile";
|
||||
|
||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const isMobile = useMobileViewport();
|
||||
const { refreshSession } = useAuth();
|
||||
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
|
||||
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
|
||||
@@ -90,14 +93,30 @@ export const ProfilePage = () => {
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Profile</h1>
|
||||
{!isMobile && <h1>Profile</h1>}
|
||||
|
||||
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
|
||||
|
||||
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
|
||||
|
||||
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||
<div className="card stack">
|
||||
isMobile ? (
|
||||
<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="avatar-preview">
|
||||
{avatarUrl ? (
|
||||
@@ -178,7 +197,7 @@ export const ProfilePage = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,12 +57,20 @@ export const ToolWorkshopPage = () => {
|
||||
mobileView={mobileView}
|
||||
isCreating={isCreating}
|
||||
toolTypeForm={toolTypeForm}
|
||||
manifestData={manifestData}
|
||||
manifestDefinitionId={manifestDefinitionId}
|
||||
baseDefinitions={baseDefinitions}
|
||||
toolTypeError={toolTypeError}
|
||||
toolTypeDirty={toolTypeDirty}
|
||||
onViewChange={setMobileView}
|
||||
onSelect={handleSelectToolType}
|
||||
onCreate={handleCreateNew}
|
||||
onDelete={handleDeleteToolType}
|
||||
onFormChange={handleFormChange}
|
||||
onManifestChange={(m) => {
|
||||
setManifestData(m);
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
onSubmit={handleToolTypeSubmit}
|
||||
onCancel={() => {
|
||||
if (toolTypeDirty) {
|
||||
@@ -70,7 +78,7 @@ export const ToolWorkshopPage = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMobileView(isCreating ? "list" : "detail");
|
||||
setMobileView("list");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/styles
|
||||
|
||||
## role
|
||||
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, design tokens, utility classes, and component-specific styles.
|
||||
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, theme tokens, utility classes, and component-specific styles.
|
||||
## parent
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
map: apps/web/src/.pi-map.md
|
||||
|
||||
@@ -4,16 +4,16 @@ dir: apps/web/src/styles
|
||||
index: apps/web/src/styles/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, design tokens, utility classes, and component-specific styles.
|
||||
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, theme tokens, utility classes, and component-specific styles.
|
||||
## files
|
||||
- global.css | Defines global CSS styles for a web application shell layout, navigation, cards, forms, dialogs, settings pages, and responsive design patterns. | dep: CSS custom properties (CSS variables: --border, --panel, --brand, --muted, --ink, --bg, --danger, --success, --warning, --font-size-xs, --font-size-sm, --space-2, --space-3, --space-4, --space-5)
|
||||
- syntax-highlight.css | Stylesheet for a syntax highlighting component with toolbar, line numbers, code display, and Prism.js theme integration | dep: Prism.js
|
||||
- tokens.css | Defines a comprehensive CSS design token system with light/dark themes, spacing scales, breakpoints, and fluid typography for a web application.
|
||||
- utilities.css | Provides responsive CSS utility classes and component-specific styles for a web application featuring terminals, dialogs, tables, forms, and navigation with mobile-first breakpoints | dep: CSS custom properties (variables like --space-*, --border, --bg, --brand, --muted, --success, --danger, --text-xs, --text-sm), xterm.js (terminal library)
|
||||
- utilities.css | Provides a comprehensive CSS utility system for responsive layouts, terminal interfaces, and UI components with mobile-first design patterns. | dep: CSS custom properties (--space-*, --border, --bg, --muted, --success, --text, --brand, --danger, --danger-light), xterm.js (terminal library)
|
||||
## arch
|
||||
CSS custom properties (variables) based theming system with light/dark mode support, mobile-first responsive breakpoints, fluid typography scales, utility-first class patterns, and modular separation of concerns across tokens, utilities, global layouts, and component-specific styles.
|
||||
Token-driven CSS architecture using CSS custom properties for themeable design system with mobile-first responsive utilities, separate component-specific stylesheets, and light/dark mode support.
|
||||
## tags
|
||||
space, global, css, web, application, syntax, defines, styles
|
||||
space, global, css, design, danger, syntax, defines, web
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
|
||||
@@ -272,38 +272,65 @@ a.nav-item,
|
||||
|
||||
/* Session Navigation */
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
|
||||
.session-icon-wrap {
|
||||
position: relative;
|
||||
padding-left: var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.session-icon-wrap .icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
position: absolute;
|
||||
left: var(--space-2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
top: -2px;
|
||||
right: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
border: 2px solid var(--panel);
|
||||
}
|
||||
|
||||
.session-status.running {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.session-name {
|
||||
.session-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.session-workspace,
|
||||
.session-context {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.session-tool {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-left: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
.session-display-name {
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
@@ -439,6 +466,7 @@ a.nav-item,
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-page-header {
|
||||
@@ -457,6 +485,7 @@ a.nav-item,
|
||||
grid-template-rows: auto 1fr;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
@@ -549,6 +578,7 @@ a.nav-item,
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -557,6 +587,16 @@ a.nav-item,
|
||||
.terminal-container .xterm-viewport {
|
||||
touch-action: auto;
|
||||
overscroll-behavior: auto;
|
||||
max-height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* On mobile the custom touch handler scrolls the buffer; disable native
|
||||
panning/overscroll so swipe gestures are not forwarded to a scrollbar. */
|
||||
.terminal-wrapper.mobile .terminal-container,
|
||||
.terminal-wrapper.mobile .terminal-container .xterm-viewport {
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* xterm.js manages its own positioning and sizing */
|
||||
@@ -767,7 +807,9 @@ a.nav-item,
|
||||
.terminal-instance {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-instance.active {
|
||||
@@ -1764,7 +1806,7 @@ a.nav-item,
|
||||
/* Disable zoom on mobile terminal */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-terminal-wrapper {
|
||||
touch-action: pan-y;
|
||||
touch-action: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
@@ -1774,9 +1816,14 @@ a.nav-item,
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
touch-action: pan-y;
|
||||
touch-action: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-wrapper.mobile .terminal-container .xterm-viewport {
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@@ -2053,10 +2100,38 @@ a.nav-item,
|
||||
.mobile-list-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-list-item-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-list-item-action:active {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mobile-list-item-action-delete {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.mobile-list-item-action-delete:active {
|
||||
background: var(--danger-light);
|
||||
}
|
||||
|
||||
.mobile-list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2109,6 +2184,199 @@ a.nav-item,
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Mobile Page */
|
||||
.mobile-page {
|
||||
padding: var(--space-2);
|
||||
padding-bottom: calc(var(--space-4) + 64px);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Mobile Edit View */
|
||||
.mobile-edit-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mobile-edit-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mobile-edit-cancel,
|
||||
.mobile-edit-save,
|
||||
.mobile-edit-delete {
|
||||
min-height: 44px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-edit-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-edit-save {
|
||||
background: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mobile-edit-delete {
|
||||
background: var(--danger-light);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.mobile-edit-save:disabled,
|
||||
.mobile-edit-cancel:disabled,
|
||||
.mobile-edit-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mobile-edit-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-edit-form {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
padding-bottom: calc(var(--space-3) + 148px);
|
||||
}
|
||||
|
||||
/* Mobile Edit Actions (sticky bottom bar) */
|
||||
.mobile-edit-actions {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px;
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.mobile-edit-actions .mobile-edit-save,
|
||||
.mobile-edit-actions .mobile-edit-delete {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mobile-edit-field {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-edit-field-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-edit-input,
|
||||
.mobile-edit-textarea,
|
||||
.mobile-edit-select {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-edit-textarea {
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.mobile-edit-code {
|
||||
font-family:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mobile-edit-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-edit-checkbox input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
/* Mobile Profile */
|
||||
.profile-mobile-avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6) var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.profile-mobile-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-mobile-avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-mobile-avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Mobile Page Header */
|
||||
.mobile-page-header {
|
||||
display: flex;
|
||||
@@ -2179,13 +2447,351 @@ a.nav-item,
|
||||
|
||||
/* Mobile form group (inline fields) */
|
||||
.mobile-form-group {
|
||||
padding: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-form-input,
|
||||
.mobile-form-select,
|
||||
.mobile-form-textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.mobile-form-code {
|
||||
font-family:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mobile-form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.mobile-form-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-form-checkbox {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
accent-color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-form-help {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.mobile-form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.mobile-discard-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Manifest editor responsive overrides inside mobile forms */
|
||||
.mobile-manifest-editor-wrapper .card {
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-manifest-editor-wrapper .row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-manifest-editor-wrapper .form-group,
|
||||
.mobile-manifest-editor-wrapper input,
|
||||
.mobile-manifest-editor-wrapper select,
|
||||
.mobile-manifest-editor-wrapper textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-manifest-editor-wrapper input[style*="width:"] {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Mobile config profile form extras */
|
||||
.mobile-form-group p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-form-section {
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-form-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-form-section-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mobile-add-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-form-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.mobile-list-plain {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mobile-list-plain li {
|
||||
padding: var(--space-2);
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mobile detail extra actions */
|
||||
.mobile-detail-actions-extra {
|
||||
padding: var(--space-3);
|
||||
padding-top: 0;
|
||||
margin-top: calc(var(--space-2) * -1);
|
||||
}
|
||||
|
||||
.mobile-detail-actions-extra .secondary-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Mobile includes */
|
||||
.mobile-includes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-include-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-include-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-include-scope {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* Mobile key/value rows */
|
||||
.mobile-key-value-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-key-value-row .mobile-form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile file and mount cards */
|
||||
.mobile-file-card,
|
||||
.mobile-mount-card {
|
||||
padding: var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-file-header,
|
||||
.mobile-mount-header {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-file-header .mobile-form-input,
|
||||
.mobile-mount-header .mobile-form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-mount-files {
|
||||
padding-left: var(--space-3);
|
||||
border-left: 2px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-mount-file-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mobile-mount-file-row .mobile-form-input,
|
||||
.mobile-mount-file-row .mobile-form-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile preview */
|
||||
.mobile-preview-code {
|
||||
background: var(--code-bg, #1e1e1e);
|
||||
color: var(--code-fg, #d4d4d4);
|
||||
padding: var(--space-3);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
font-family:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
margin: var(--space-2) 0 0 0;
|
||||
}
|
||||
|
||||
.mobile-preview-item {
|
||||
padding: var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mobile-preview-item strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mobile-preview-mapping {
|
||||
font-family:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Mobile git mount editor overrides */
|
||||
.mobile-form-section .git-mount-editor .card {
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-form-section .git-mount-editor .form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-form-section .git-mount-editor input,
|
||||
.mobile-form-section .git-mount-editor select,
|
||||
.mobile-form-section .git-mount-editor textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-form-section .git-mount-editor .form-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.mobile-form-section .git-mount-editor button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Ensure minimum touch targets on mobile */
|
||||
button,
|
||||
a,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: docs
|
||||
|
||||
## role
|
||||
Provides user-facing documentation and knowledge base for the Headquarter platform.
|
||||
Central documentation hub and navigation entry point for the Headquarter platform.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user