Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886c863260 | |||
| 3c25fffd49 | |||
| add7c1b500 | |||
| f9f9372ee7 | |||
| ea42165ed2 | |||
| c178225c8b | |||
| b5e961ebe9 | |||
| 5d379c5f8b | |||
| 671540ded9 | |||
| ad26fd9f35 | |||
| 415aecc0dd | |||
| bb38b37ceb | |||
| 6698c20f25 | |||
| 5e06a2a226 | |||
| 49180e4c6d | |||
| 7a5538b53f | |||
| 41d24beade | |||
| d1777b88ad | |||
| 691bbaa87b | |||
| 6191565e80 | |||
| 468f34202a | |||
| 996a858892 | |||
| ff7fcb6e8d | |||
| 3f06224b75 | |||
| a1a77c99a6 | |||
| 3c96c7b153 | |||
| 10955dfe8e |
+4
-14
@@ -16,7 +16,7 @@ dir: .
|
|||||||
Trust boundary: index routes, map orients, source decides.
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Root configuration and documentation package for "Headquarter," a self-hosted project management platform with Git repository features, containing deployment configs, development tooling, and UI rework tracking documents.
|
Package .
|
||||||
## parent
|
## parent
|
||||||
-
|
-
|
||||||
## children
|
## children
|
||||||
@@ -32,15 +32,6 @@ Root configuration and documentation package for "Headquarter," a self-hosted pr
|
|||||||
- .pi
|
- .pi
|
||||||
index: .pi/.pi-map.index.md
|
index: .pi/.pi-map.index.md
|
||||||
map: .pi/.pi-map.md
|
map: .pi/.pi-map.md
|
||||||
- .pi-lens
|
|
||||||
index: .pi-lens/.pi-map.index.md
|
|
||||||
map: .pi-lens/.pi-map.md
|
|
||||||
- .pytest_cache
|
|
||||||
index: .pytest_cache/.pi-map.index.md
|
|
||||||
map: .pytest_cache/.pi-map.md
|
|
||||||
- .ruff_cache
|
|
||||||
index: .ruff_cache/.pi-map.index.md
|
|
||||||
map: .ruff_cache/.pi-map.md
|
|
||||||
- .sisyphus
|
- .sisyphus
|
||||||
index: .sisyphus/.pi-map.index.md
|
index: .sisyphus/.pi-map.index.md
|
||||||
map: .sisyphus/.pi-map.md
|
map: .sisyphus/.pi-map.md
|
||||||
@@ -62,12 +53,12 @@ Root configuration and documentation package for "Headquarter," a self-hosted pr
|
|||||||
- scripts
|
- scripts
|
||||||
index: scripts/.pi-map.index.md
|
index: scripts/.pi-map.index.md
|
||||||
map: scripts/.pi-map.md
|
map: scripts/.pi-map.md
|
||||||
- tests
|
|
||||||
index: tests/.pi-map.index.md
|
|
||||||
map: tests/.pi-map.md
|
|
||||||
- tool-images
|
- tool-images
|
||||||
index: tool-images/.pi-map.index.md
|
index: tool-images/.pi-map.index.md
|
||||||
map: tool-images/.pi-map.md
|
map: tool-images/.pi-map.md
|
||||||
|
- uploads
|
||||||
|
index: uploads/.pi-map.index.md
|
||||||
|
map: uploads/.pi-map.md
|
||||||
## files
|
## files
|
||||||
- .env.example
|
- .env.example
|
||||||
- .gitignore
|
- .gitignore
|
||||||
@@ -77,7 +68,6 @@ Root configuration and documentation package for "Headquarter," a self-hosted pr
|
|||||||
- README.md
|
- README.md
|
||||||
- docker-compose.traefik.yml
|
- docker-compose.traefik.yml
|
||||||
- docker-compose.yml
|
- docker-compose.yml
|
||||||
- package-lock.json
|
|
||||||
- progress.md
|
- progress.md
|
||||||
- swap-pane
|
- swap-pane
|
||||||
- ui-audit-spacing-typography.md
|
- ui-audit-spacing-typography.md
|
||||||
|
|||||||
+16
-17
@@ -18,26 +18,25 @@ index: ./.pi-map.index.md
|
|||||||
Trust boundary: index routes, map orients, source decides.
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Root configuration and documentation package for "Headquarter," a self-hosted project management platform with Git repository features, containing deployment configs, development tooling, and UI rework tracking documents.
|
Package .
|
||||||
## files
|
## files
|
||||||
- .env.example | Provides a template of environment variables for configuring database, Redis, sessions, domains, Authentik OAuth, frontend, and Docker settings for the application. | dep: Postgres, Redis, Authentik, Vite, Docker Compose, Traefik
|
- .env.example | Provides example environment variable configuration for a full-stack application with database, caching, authentication, and deployment settings
|
||||||
- .gitignore | Specifies intentionally untracked files and directories to exclude from version control across multiple technologies and environments.
|
- .gitignore | Specifies files and directories for Git to ignore across a project using Beads/Dolt, Python, Node, and various IDE/OS tooling. | dep: git, dolt, beads, python, node, npm, yarn, pnpm, pytest, mypy, ruff, coverage
|
||||||
- AGENTS.md | Defines rules, workflow conventions, and verification standards for AI agents operating on the project, using OpenSpec as the source of truth and Superpowers as the default workflow. | dep: OpenSpec, Superpowers, Git
|
- AGENTS.md | Defines operational guidelines and workflows for AI agents collaborating on a software project governed by OpenSpec | dep: OpenSpec, superpowers (brainstorming, writing-plans, test-driven-development, systematic-debugging, verification-before-completion, using-git-worktrees, dispatching-parallel-agents), git
|
||||||
- CHANGELOG.md | Documents notable changes and version history for a project management application with Git repository features.
|
- CHANGELOG.md | Documents version history and notable changes for a project management and Git repository application
|
||||||
- Makefile | Provides common development automation commands for a Dockerized full-stack application, including service lifecycle, testing, linting, migrations, and cleanup. | dep: docker compose, alembic, pytest, playwright, ruff, mypy, npm
|
- Makefile | Provides standardized development commands for managing a Docker-based full-stack application with API, web frontend, database, and testing infrastructure | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis
|
||||||
- README.md | Provides documentation and onboarding instructions for "Headquarter," a self-hosted platform for managing projects, git repositories, and development tools.
|
- README.md | Documentation for a self-hosted development platform that manages projects, git repositories, and development tools with OAuth2 authentication | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik
|
||||||
- docker-compose.traefik.yml | Defines a Docker Compose deployment configuration for a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination. | dep: postgres:15-alpine, redis:7-alpine, traefik, docker.sock, authentik
|
- docker-compose.traefik.yml | Deploys a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination | dep: docker, docker-compose, traefik, postgres, redis, node/vite, python/fastapi
|
||||||
- docker-compose.yml | Defines a multi-container Docker Compose configuration for a full-stack application with PostgreSQL, Redis, an API service, and a web frontend. | dep: postgres:15-alpine, redis:7-alpine, apps/api, apps/web
|
- docker-compose.yml | Defines a multi-service Docker Compose stack for a web application with PostgreSQL, Redis, API backend, and web frontend services | dep: Docker, Docker Compose, PostgreSQL, Redis, Vite, asyncpg, Python/FastAPI (implied), Node.js (implied)
|
||||||
- package-lock.json | Locks dependency versions for the "headquarter" npm package.
|
- progress.md | Documents the progress and remaining tasks for a backend-frontend refactoring project involving modularization, code reorganization, and verification.
|
||||||
- progress.md | Tracks progress of a backend-frontend refactoring project across completed phases, pending tasks, and remaining work.
|
- swap-pane | Provides a command to swap the position of two tmux panes within a window or between windows | dep: tmux, client, window, layout, cmd-find, cmd-parse, options
|
||||||
- swap-pane | Empty package initialization file that marks a directory as a Python package.
|
- ui-audit-spacing-typography.md | A detailed audit report identifying critical CSS styling gaps, mobile layout failures, and design system inconsistencies in a web application's UI components and stylesheets. | dep: React/TSX components, CSS stylesheets (global.css, utilities.css, tokens.css, page-specific CSS), JSX/TSX files in apps/web/src/components and apps/web/src/pages
|
||||||
- ui-audit-spacing-typography.md | Audit report documenting spacing, typography, and visual rhythm issues in the web UI, including missing CSS classes, inconsistent units, incomplete design tokens, and recommendations for fixes.
|
- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including design token expansion, primitive CSS class additions, component refactors, and OpenSpec documentation. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, Git/SSH, npm build toolchain
|
||||||
- ui-rework-foundations-apply.md | Documenting the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, summarizing changed files, commits, validation results, and next steps. | dep: tokens.css, global.css, utilities.css, ConfigProfileListSidebar.tsx, ConfigProfileEditorPanel.tsx, instance-list.tsx, openspec artifacts
|
- ui-rework-pass2-apply.md | Documents the implementation and verification of Pass 2 of a web UI spacing/typography rework, refactoring inline styles into utility classes and design tokens across six components. | dep: CSS custom properties, BEM methodology, utility-first CSS, React/TSX components, OpenSpec documentation system
|
||||||
- ui-rework-pass2-apply.md | Implementation report documenting a UI component cleanup pass that refactored inline styles into CSS utility classes and design tokens.
|
|
||||||
## arch
|
## arch
|
||||||
Dockerized microservices architecture with PostgreSQL/Redis backend, separate API and web frontend services, Traefik reverse proxy option, npm-based frontend build, and Makefile-driven development workflow with phased UI refactoring tracked via markdown documents.
|
Contains 13 files.
|
||||||
## tags
|
## tags
|
||||||
docker, ui, application, web, package, css, rework, redis
|
docker, web, git, application, ui, rework, python, redis
|
||||||
## symbols
|
## symbols
|
||||||
-
|
-
|
||||||
## workflows
|
## workflows
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps
|
dir: apps
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Contains the main application entry points and executable components for the project
|
Package apps
|
||||||
## parent
|
## parent
|
||||||
index: ./.pi-map.index.md
|
index: ./.pi-map.index.md
|
||||||
map: ./.pi-map.md
|
map: ./.pi-map.md
|
||||||
|
|||||||
+2
-2
@@ -4,10 +4,10 @@ dir: apps
|
|||||||
index: apps/.pi-map.index.md
|
index: apps/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Contains the main application entry points and executable components for the project
|
Package apps
|
||||||
## files
|
## files
|
||||||
## arch
|
## arch
|
||||||
Modular application structure with separate deployable units, typically following framework-specific patterns (e.g., MVC, serverless handlers, or microservices)
|
Contains 0 files.
|
||||||
## tags
|
## tags
|
||||||
-
|
-
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api
|
dir: apps/api
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Backend API server for the Headquarter platform, providing self-hosted project management, git repository orchestration, and development tool integration.
|
Backend API server for the Headquarter platform, providing self-hosted project management, git repository management, and development tool orchestration services.
|
||||||
## parent
|
## parent
|
||||||
index: apps/.pi-map.index.md
|
index: apps/.pi-map.index.md
|
||||||
map: apps/.pi-map.md
|
map: apps/.pi-map.md
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ dir: apps/api
|
|||||||
index: apps/api/.pi-map.index.md
|
index: apps/api/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Backend API server for the Headquarter platform, providing self-hosted project management, git repository orchestration, and development tool integration.
|
Backend API server for the Headquarter platform, providing self-hosted project management, git repository management, and development tool orchestration services.
|
||||||
## files
|
## files
|
||||||
- .dockerignore | Specifies files and directories to exclude from the Docker build context to optimize image build times and prevent sensitive or unnecessary files from being included.
|
- .dockerignore | Specifies files and directories to exclude from the Docker build context to optimize image build times and prevent sensitive or unnecessary files from being included.
|
||||||
- Dockerfile | Multi-stage Dockerfile that builds and runs a Python application with Docker CLI access, cloudflared, and database readiness checks. | dep: python:3.11-slim, libpq5, git, openssh-client, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, netcat-openbsd
|
- Dockerfile | Multi-stage Dockerfile that builds and runs a Python application with Docker CLI access, cloudflared, and database readiness checks. | dep: python:3.11-slim, libpq5, git, openssh-client, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, netcat-openbsd
|
||||||
@@ -14,7 +14,7 @@ Backend API server for the Headquarter platform, providing self-hosted project m
|
|||||||
- uv.lock | This file is a UV lockfile that pins exact versions, hashes, and metadata for all Python project dependencies to ensure reproducible environments. | dep: uv, aiosqlite, alembic, annotated-types, anyio, asyncpg, sqlalchemy, mako
|
- uv.lock | This file is a UV lockfile that pins exact versions, hashes, and metadata for all Python project dependencies to ensure reproducible environments. | dep: uv, aiosqlite, alembic, annotated-types, anyio, asyncpg, sqlalchemy, mako
|
||||||
- wait-for-db.sh | Polls a PostgreSQL host/port until it is available or a retry limit is reached, then executes the passed command. | dep: nc, sleep
|
- wait-for-db.sh | Polls a PostgreSQL host/port until it is available or a retry limit is reached, then executes the passed command. | dep: nc, sleep
|
||||||
## arch
|
## arch
|
||||||
Python-based service using FastAPI/ASGI with Alembic for database migrations, multi-stage Docker containerization with cloudflared tunneling, UV for dependency management, and PostgreSQL as the primary datastore with explicit readiness-gated startup orchestration.
|
Modern Python async API using FastAPI/Starlette with SQLAlchemy ORM, Alembic migrations, multi-stage Docker containerization with cloudflared tunneling, UV package management, and PostgreSQL database with health-checked startup orchestration.
|
||||||
## tags
|
## tags
|
||||||
alembic, docker, sqlalchemy, asyncpg, dockerfile, database, postgresql, pydantic
|
alembic, docker, sqlalchemy, asyncpg, dockerfile, database, postgresql, pydantic
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/alembic
|
dir: apps/api/alembic
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Database migration infrastructure for the API application, enabling version-controlled schema changes with async database support.
|
Database migration tooling for the API service, enabling version-controlled schema changes with async SQLAlchemy support.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/.pi-map.index.md
|
index: apps/api/.pi-map.index.md
|
||||||
map: apps/api/.pi-map.md
|
map: apps/api/.pi-map.md
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ dir: apps/api/alembic
|
|||||||
index: apps/api/alembic/.pi-map.index.md
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Database migration infrastructure for the API application, enabling version-controlled schema changes with async database support.
|
Database migration tooling for the API service, enabling version-controlled schema changes with async SQLAlchemy support.
|
||||||
## files
|
## files
|
||||||
- env.py | Configures Alembic database migration environment with async SQLAlchemy support | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio
|
- env.py | Configures Alembic database migration environment with async SQLAlchemy support. | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio
|
||||||
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQL schema revisions | dep: alembic, sqlalchemy
|
- script.py.mako | Alembic database migration script template that generates Python migration files for SQLAlchemy database schema changes | dep: alembic, sqlalchemy, mako
|
||||||
## arch
|
## arch
|
||||||
Template-based migration generation using Alembic with async SQLAlchemy engine configuration and environment setup.
|
Template-based migration generation using Alembic's standard env.py configuration pattern with async SQLAlchemy engine integration and Mako templating for migration script scaffolding.
|
||||||
## tags
|
## tags
|
||||||
migrations, run, async, sqlalchemy, alembic, call:context.configure, call:context.begin, transaction
|
migrations, run, sqlalchemy, async, alembic, call:context.configure, call:context.begin, transaction
|
||||||
## symbols
|
## symbols
|
||||||
- run_migrations_offline
|
- run_migrations_offline
|
||||||
- do_run_migrations
|
- do_run_migrations
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
dir: apps/api/alembic/versions
|
dir: apps/api/alembic/versions
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Manages database schema evolution and version control for the API application using Alembic migration scripts.
|
Database schema versioning and incremental migration management for the API's relational data model using Alembic.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/alembic/.pi-map.index.md
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
map: apps/api/alembic/.pi-map.md
|
map: apps/api/alembic/.pi-map.md
|
||||||
## children
|
## children
|
||||||
-
|
- apps/api/alembic/versions/.ruff_cache
|
||||||
|
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/versions/.ruff_cache/.pi-map.md
|
||||||
## files
|
## files
|
||||||
- 0001_initial_schema.py
|
- 0001_initial_schema.py
|
||||||
- 0002_refresh_tokens.py
|
- 0002_refresh_tokens.py
|
||||||
@@ -51,6 +53,7 @@ map: apps/api/alembic/.pi-map.md
|
|||||||
- 2026_06_14_104415_add_tool_type_home_directory.py
|
- 2026_06_14_104415_add_tool_type_home_directory.py
|
||||||
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
|
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
|
||||||
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
|
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
|
||||||
|
- 2026_06_19_113000_remove_pi_agent_workspace_symlink.py
|
||||||
- 398082499c30_add_tool_config_fields.py
|
- 398082499c30_add_tool_config_fields.py
|
||||||
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
|
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
|
||||||
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
|
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
|
||||||
@@ -69,5 +72,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
|
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||||
- change versions config
|
- change versions config
|
||||||
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||||
|
- explore versions subdirectories
|
||||||
|
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||||
## dirty
|
## dirty
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -4,60 +4,61 @@ dir: apps/api/alembic/versions
|
|||||||
index: apps/api/alembic/versions/.pi-map.index.md
|
index: apps/api/alembic/versions/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Manages database schema evolution and version control for the API application using Alembic migration scripts.
|
Database schema versioning and incremental migration management for the API's relational data model using Alembic.
|
||||||
## files
|
## files
|
||||||
- 0001_initial_schema.py | Creates the initial database schema with five tables (users, ssh_keys, projects, git_repositories, user_configs) using Alembic migrations | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 0001_initial_schema.py | Alembic database migration that creates the initial schema with five tables (users, ssh_keys, projects, git_repositories, user_configs) for a Git/SSH management application. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication/session management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for secure session management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0003_user_configs.py | Alembic database migration that creates a user_configs table with JSON configuration storage linked to users via foreign key | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.JSON, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
- 0003_user_configs.py | Alembic database migration that creates a user_configs table with JSON configuration storage per user | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.JSON, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
||||||
- 0004_tool_types.py | Alembic database migration that creates a tool_types table with metadata, templates, and user relationship columns | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
- 0004_tool_types.py | Alembic database migration that creates a tool_types table with metadata, templates, and user tracking fields | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
||||||
- 0005_ssh_keys_timestamps.py | Alembic database migration that adds created_at and updated_at timestamp columns to the ssh_keys table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 0005_ssh_keys_timestamps.py | Alembic database migration that adds created_at and updated_at timestamp columns to the ssh_keys table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 0006_tool_instances.py | Alembic database migration that creates a tool_instances table with UUID primary key, foreign key relationships, timestamps, and indexes for tracking deployed tool instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Integer, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
- 0006_tool_instances.py | Alembic database migration that creates a tool_instances table with foreign key relationships to tool_types, git_repositories, projects, and users tables, plus indexes for common query patterns. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Integer, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0007_instance_container_name.py | Alembic database migration that adds a nullable container_name column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 0007_instance_container_name.py | Alembic database migration that adds a nullable container_name column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 0008_tool_type_category.py | Alembic database migration that adds 'category' and 'interfaces' columns to the 'tool_types' table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 0008_tool_type_category.py | Alembic database migration that adds `category` and `interfaces` columns to the `tool_types` table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 0009_tool_configs.py | Alembic database migration that creates a tool_configs table for storing user tool configuration key-value pairs with optional project scoping | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 0009_tool_configs.py | Alembic database migration that creates a tool_configs table with UUID primary keys, foreign keys to users/tool_types/projects, key-value configuration storage, and supporting indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0010_tool_type_default_port.py | Alembic database migration that adds a nullable default_port column to the tool_types table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 0010_tool_type_default_port.py | Alembic database migration that adds a nullable default_port column to the tool_types table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 0011_tool_instance_tunnel_fields.py | Alembic database migration that adds public_url and tunnel_id columns to the tool_instances table for tunnel functionality. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 0011_tool_instance_tunnel_fields.py | Alembic database migration that adds tunnel-related fields (public_url and tunnel_id) to the tool_instances table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 0012_default_port_req.py | Alembic database migration that backfills default_port values for existing tool types and makes the column non-nullable | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.Integer, func:downgrade() → None, call:op.alter_column, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
- 0012_default_port_req.py | Alembic database migration that populates default_port values for existing tool types and makes the column non-nullable | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.Integer, func:downgrade() → None, call:op.alter_column, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
||||||
- 0013_add_config_profiles.py | Database migration that adds config profiles, profile includes, profile mounts, and tool instance profile selection tables with defensive idempotent checks | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:upgrade() → None, call:_table_exists, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:_index_exists, call:op.create_index, call:sa.Integer, call:_column_exists, call:op.add_column, call:_foreign_key_exists, call:op.create_foreign_key, func:downgrade() → None, call:op.drop_index, call:op.drop_constraint, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 0013_add_config_profiles.py | Alembic database migration that adds config_profiles, config_includes, config_mounts tables and a selected_profile_id column to tool_instances with defensive checks for idempotent execution | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:upgrade() → None, call:_table_exists, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:_index_exists, call:op.create_index, call:sa.Integer, call:_column_exists, call:op.add_column, call:_foreign_key_exists, call:op.create_foreign_key, func:downgrade() → None, call:op.drop_index, call:op.drop_constraint, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0013_add_probe_result.py | Alembic database migration that adds a JSON probe_result column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 0013_add_probe_result.py | Database migration to add a JSON probe_result column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0014_add_profile_resolver_fields.py | Alembic database migration that adds profile resolver fields (project_id, tool_type_id, environment_variables, start_command, working_directory, port, is_default) to config_profiles table, renames mount_path to target_path and adds mode/files columns to config_mounts table while removing source_profile_id and content columns | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:_foreign_key_names_for_column(table_name: str, column_name: str) → list[str], call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, call:names.append, func:upgrade() → None, call:_column_exists, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.JSON, call:sa.Text, call:sa.Integer, call:sa.Boolean, call:_foreign_key_exists, call:op.create_foreign_key, call:_index_exists, call:op.create_index, call:op.alter_column, call:sa.String, call:_foreign_key_names_for_column, call:op.drop_constraint, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.Text, call:op.drop_column, call:op.alter_column, call:op.drop_index, call:op.drop_constraint | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 0014_add_profile_resolver_fields.py | Alembic database migration that adds profile resolver fields to config_profiles and config_mounts tables with idempotent schema checks | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:_foreign_key_names_for_column(table_name: str, column_name: str) → list[str], call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, call:names.append, func:upgrade() → None, call:_column_exists, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.JSON, call:sa.Text, call:sa.Integer, call:sa.Boolean, call:_foreign_key_exists, call:op.create_foreign_key, call:_index_exists, call:op.create_index, call:op.alter_column, call:sa.String, call:_foreign_key_names_for_column, call:op.drop_constraint, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.Text, call:op.drop_column, call:op.alter_column, call:op.drop_index, call:op.drop_constraint | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 0014_merge_heads.py | Alembic merge migration that combines two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
- 0014_merge_heads.py | Alembic merge migration that reconciles two divergent migration branches without applying schema changes | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||||
- 0015_single_interface.py | Alembic database migration that replaces a plural JSON interfaces column with singular interface_type and requires_port columns on tool_types table | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 0015_single_interface.py | Alembic database migration that replaces a JSON array `interfaces` column with a single `interface_type` string column and adds a `requires_port` boolean column, with dialect-specific SQL for PostgreSQL and SQLite data migration. | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds an ssh_key_id foreign key column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds an ssh_key_id foreign key column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
||||||
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a new "pi-agent" tool type with terminal-based Docker development environment configuration | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
|
- 20260527160017_add_pi_agent_tool_type.py | Adds a database migration that inserts a new "pi-agent" tool type into a tool_types table, defining a terminal-based Docker development environment with nvim, ranger, and tmux. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
|
||||||
- 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories and clone_mode/branch columns to tool_instances | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 2026_05_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 that removes the `is_builtin` column from the `tool_types` table with downgrade support to restore it. | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
|
- 2026_05_23_remove_is_builtin.py | Alembic database migration that removes the `is_builtin` column from the `tool_types` table with a downgrade path to restore it. | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
|
||||||
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable startup_command column to the tool_types table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 2026_05_24_add_config_profiles.py | Alembic database migration that creates config_profiles and config_profile_includes tables with indexes, and adds a selected_config_profile_id foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:sa.Integer, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 2026_05_24_add_config_profiles.py | Alembic database migration that creates config_profiles and config_profile_includes tables with indexes, and adds a selected_config_profile_id foreign key to tool_instances. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:sa.Integer, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
- 2026_05_26_add_git_mounts.py | Alembic database migration that adds a git_mounts JSON column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
- 2026_05_26_add_git_mounts.py | Database migration to add a `git_mounts` JSON column to the `config_profiles` table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
- 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories to support external repositories and expands alembic_version version_num column to prevent truncation. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
|
- 2026_05_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 VARCHAR(64). | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
|
||||||
- 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health check status. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
- 2026_05_28_add_monitoring_tables.py | Creates two database tables (instance_events and health_checks) for monitoring tool instances via an Alembic migration. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with foreign key to tool_instances for tracking terminal session lifecycle | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with foreign key to tool_instances, including indexes and audit timestamps | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest support to tool_types and tool_instances, and migrates the pi-agent tool from Dockerfile/compose templates to a JSON-based manifest system with a base Ubuntu image definition. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
|
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest support to tool_types and tool_instances, and migrates the pi-agent tool to a manifest-based definition with a base Ubuntu image. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
|
||||||
- 2026_05_28_drop_tool_configs_and_config_folders.py | Database migration that drops the `tool_configs` and `config_folders` tables with conditional existence checks, and provides downgrade to recreate them. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
- 2026_05_28_drop_tool_configs_and_config_folders.py | Alembic database migration that drops the tool_configs and config_folders tables with conditional existence checks, and provides downgrade to recreate them | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
||||||
- 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, and severity-based notification records including read/dismissed tracking and optimized indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
- 2026_05_29_add_notifications_table.py | Creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed states and optimized querying via partial indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON ssh_key_ids column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON ssh_key_ids column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
||||||
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py | Alembic database migration that removes the ssh_key_id column from the config_profiles table | exp: func:upgrade() → None, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey | dep: alembic, sqlalchemy
|
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py | Alembic database migration that removes the ssh_key_id column from the config_profiles table | exp: func:upgrade() → None, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey | dep: alembic, sqlalchemy
|
||||||
- 2026_05_29_fix_code_server_bind_addr.py | Alembic database migration that fixes code-server tool type compose templates by replacing deprecated --bind-addr flag with --host flag | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--bind-addr%' """) ).fetchall, call:sa.text, call:compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" ).replace, call:print, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
- 2026_05_29_fix_code_server_bind_addr.py | Alembic database migration that fixes code-server tool type compose templates by replacing deprecated --bind-addr flag with --host flag | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--bind-addr%' """) ).fetchall, call:sa.text, call:compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" ).replace, call:print, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
||||||
- 2026_05_29_fix_code_server_bind_addr_port.py | Alembic database migration that fixes code-server Docker compose templates by replacing broken `--host` flags with correct `--bind-addr 0.0.0.0:{port}` configurations in both database tool_type templates and on-disk instance compose files. | exp: func:_fix_tool_type_templates(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, default_port FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--host%' """) ).fetchall, call:sa.text, call:compose_template.split, call:len, call:line.lstrip, call:new_lines.append, call:"\n".join, call:print, func:_fix_instance_compose_files(conn) → None, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:sa.text, call:print, call:conn.execute( sa.text(""" SELECT id, compose_path, tool_type_id FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:conn.execute( sa.text(""" SELECT default_port FROM tool_types WHERE id = :id """), {"id": tool_type_id}, ).fetchone, call:yaml.safe_load, call:data["services"].values, call:path.write_text, call:yaml.dump, func:upgrade() → None, call:op.get_bind, call:_fix_tool_type_templates, call:_fix_instance_compose_files, func:downgrade() → None | dep: typing, alembic, yaml, pathlib, sqlalchemy
|
- 2026_05_29_fix_code_server_bind_addr_port.py | Alembic database migration that fixes code-server Docker compose templates by replacing incorrect `--host` flags with proper `--bind-addr 0.0.0.0:PORT` configurations in both database-stored tool type templates and on-disk instance compose files. | exp: func:_fix_tool_type_templates(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, default_port FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--host%' """) ).fetchall, call:sa.text, call:compose_template.split, call:len, call:line.lstrip, call:new_lines.append, call:"\n".join, call:print, func:_fix_instance_compose_files(conn) → None, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:sa.text, call:print, call:conn.execute( sa.text(""" SELECT id, compose_path, tool_type_id FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:conn.execute( sa.text(""" SELECT default_port FROM tool_types WHERE id = :id """), {"id": tool_type_id}, ).fetchone, call:yaml.safe_load, call:data["services"].values, call:path.write_text, call:yaml.dump, func:upgrade() → None, call:op.get_bind, call:_fix_tool_type_templates, call:_fix_instance_compose_files, func:downgrade() → None | dep: typing, alembic, yaml, pathlib, sqlalchemy
|
||||||
- 2026_05_29_fix_web_tool_bind_address.py | Alembic database migration that fixes code-server and jupyter-notebook tool compose templates to bind to 0.0.0.0 | exp: func:_fix_code_server_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'code-server' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:len, call:line.lstrip, call:new_lines.append, call:image_line.lstrip, call:new_lines.index, call:new_lines.insert, call:"\n".join, call:print, func:_fix_jupyter_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'jupyter-notebook' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:new_lines.append, call:len, call:line.lstrip, call:"\n".join, call:print, func:upgrade() → None, call:op.get_bind, call:_fix_code_server_compose, call:_fix_jupyter_compose, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
- 2026_05_29_fix_web_tool_bind_address.py | Alembic database migration that fixes web tool (code-server and jupyter-notebook) compose templates to bind to 0.0.0.0 for network accessibility. | exp: func:_fix_code_server_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'code-server' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:len, call:line.lstrip, call:new_lines.append, call:image_line.lstrip, call:new_lines.index, call:new_lines.insert, call:"\n".join, call:print, func:_fix_jupyter_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'jupyter-notebook' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:new_lines.append, call:len, call:line.lstrip, call:"\n".join, call:print, func:upgrade() → None, call:op.get_bind, call:_fix_code_server_compose, call:_fix_jupyter_compose, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
||||||
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database records and on-disk compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy
|
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host from LinuxServer.io (LSIO) 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
|
||||||
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (and can restore) the ssh_keys mount from the pi-agent tool definition manifest stored as JSON | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
|
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes the ssh_keys mount from the pi-agent manifest in the tool_definition_manifests table, with downgrade support to restore it. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
|
||||||
- 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with indexes and adds a workspace_id foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
- 2026_06_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 to make the `clone_mode` column nullable in the `tool_instances` table | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
|
- 2026_06_13_make_clone_mode_nullable.py | Alembic database migration that makes the `clone_mode` column in the `tool_instances` table nullable to allow NULL values for new rows. | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
|
||||||
- 2026_06_14_104415_add_tool_type_home_directory.py | Alembic database migration that adds a home_directory column to tool_types table and updates template strings from /workspace to a configurable path | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, func:downgrade() → None, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, call:op.drop_column | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
- 2026_06_14_104415_add_tool_type_home_directory.py | An Alembic database migration that adds a `home_directory` column to `tool_types` table and updates template strings from `/workspace` to a configurable `/home/user/{{WORKSPACE_NAME}}` path. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, func:downgrade() → None, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, call:op.drop_column | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py | Alembic database migration that updates the pi-agent tool definition manifest to mount repos under the home directory instead of /workspace | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py | Alembic database migration that fixes the pi-agent tool definition manifest's home directory mount path from /workspace to ~/{{WORKSPACE_NAME}} with a compatibility symlink and updated startup script. | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py | Alembic database migration that removes explicit repo mounts from pi-agent tool definition manifests since they are now synthesized by compile_compose | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:any, call:mount.get, call:mounts.append, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
- 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 restores them on downgrade | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:any, call:mount.get, call:mounts.append, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds configuration fields (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
- 2026_06_19_113000_remove_pi_agent_workspace_symlink.py | Alembic database migration that updates the pi-agent tool definition manifest to remove the /workspace symlink dependency and adjust working directory and startup script accordingly. | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[str | None, 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.setdefault, call:runtime.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:_update_manifest | dep: collections.abc, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two branches (removing is_builtin and adding config_profiles) with no actual schema changes | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds configuration fields (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range validation 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
|
||||||
- 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
|
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic merge migration that combines two parallel migration branches (remove_is_builtin and add_config_profiles) into a single revision history | 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
|
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Merges two Alembic migration branches (profile resolver and workspaces) into a single migration head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
- 8ed7dd80973d_create_config_folders_table.py | Creates a database migration that adds a config_folders table for storing user configuration folders with JSONB metadata, foreign key relationships, and indexing. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes or restores pi_state and pi_config mounts from a JSON manifest stored in the tool_definition_manifests table for the 'pi-agent' tool. | 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
|
||||||
- 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
|
- 8ed7dd80973d_create_config_folders_table.py | Alembic database migration that creates a config_folders table with UUID primary key, user foreign key, JSONB fields for files and project overrides, and supporting indexes/constraints | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||||
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two parent revisions (single_interface and clone_mode) into a single branch point with no actual schema changes | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds columns (definition_type, dockerfile_template, build_context, readiness_probe) and a check constraint to the tool_types table. | 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 previous migrations (single_interface and clone_mode) into a single revision point | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||||
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py | Alembic database migration that merges two branch heads (home directory and pi agent mount cleanup) into a single revision point | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py | Alembic database migration that merges two branch heads (home directory and pi agent mount cleanup) into a single revision point | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
## arch
|
## arch
|
||||||
Linear migration history with branching/merge resolution pattern; each migration is an immutable, timestamped or sequenced script containing declarative SQLAlchemy operations (create_table, add_column, drop_column) with bidirectional upgrade/downgrade functions; includes data migrations, idempotent guards, and cross-references to on-disk file mutations.
|
Linear migration history with occasional branch/merge patterns (using merge heads), sequential numbered and timestamped revision files, each containing declarative schema changes (CREATE TABLE/ALTER TABLE/DROP TABLE) with idempotent guards, downgrade paths, and occasional data migrations; supports PostgreSQL and SQLite dialects.
|
||||||
## tags
|
## tags
|
||||||
column, table, call:op.drop, downgrade, upgrade, alembic, key, call:sa.text
|
column, table, call:op.drop, downgrade, upgrade, alembic, key, call:sa.text
|
||||||
## symbols
|
## symbols
|
||||||
@@ -76,5 +77,7 @@ column, table, call:op.drop, downgrade, upgrade, alembic, key, call:sa.text
|
|||||||
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||||
- change versions config
|
- change versions config
|
||||||
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||||
|
- explore versions subdirectories
|
||||||
|
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||||
## dirty
|
## dirty
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""remove pi agent workspace symlink
|
||||||
|
|
||||||
|
Revision ID: 2026_06_19_113000
|
||||||
|
Revises: 2026_06_15_090500
|
||||||
|
Create Date: 2026-06-19 11:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.sql import column, table
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_19_113000"
|
||||||
|
down_revision: str | Sequence[str] | None = "2026_06_15_090500"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: 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[str | None, 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
|
||||||
|
|
||||||
|
runtime = manifest.setdefault("runtime", {})
|
||||||
|
# The /workspace compatibility symlink is no longer created by the
|
||||||
|
# compiler/entrypoint. Leaving working_dir set to /workspace causes the
|
||||||
|
# container to start in an empty directory. Let compile_compose default
|
||||||
|
# to /home/user/{workspace_name} instead.
|
||||||
|
if runtime.get("working_dir") == "/workspace":
|
||||||
|
del runtime["working_dir"]
|
||||||
|
|
||||||
|
scripts = manifest.setdefault("scripts", {})
|
||||||
|
# Update the startup script to operate on the real repo-named directory.
|
||||||
|
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
|
||||||
|
|
||||||
|
runtime = manifest.setdefault("runtime", {})
|
||||||
|
runtime["working_dir"] = "/workspace"
|
||||||
|
|
||||||
|
scripts = manifest.setdefault("scripts", {})
|
||||||
|
scripts["startup"] = [
|
||||||
|
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
|
||||||
|
]
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
@@ -9,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
ConfigProfileIncludeUpdate,
|
ConfigProfileIncludeUpdate,
|
||||||
@@ -33,6 +34,7 @@ from src.services.config.crud_service import (
|
|||||||
update_profile,
|
update_profile,
|
||||||
validate_default_profiles,
|
validate_default_profiles,
|
||||||
)
|
)
|
||||||
|
from src.services.tool.instance_service import resolve_git_mounts
|
||||||
from src.services.config.resolver_service import (
|
from src.services.config.resolver_service import (
|
||||||
resolve_default_profile,
|
resolve_default_profile,
|
||||||
validate_git_url,
|
validate_git_url,
|
||||||
@@ -43,6 +45,34 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _running_profile_outcomes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Report running instances that must restart to adopt a profile revision."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(ToolInstance.status == "running")
|
||||||
|
)
|
||||||
|
outcomes: list[dict[str, str]] = []
|
||||||
|
for instance in result.scalars().all():
|
||||||
|
if instance.selected_config_profile_id is None:
|
||||||
|
continue
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
dependencies = {resolved.profile_id} | {
|
||||||
|
uuid.UUID(item["id"])
|
||||||
|
for item in resolved.included_profiles
|
||||||
|
if item.get("id")
|
||||||
|
}
|
||||||
|
if profile_id in dependencies:
|
||||||
|
outcomes.append(
|
||||||
|
{
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": "restart_required",
|
||||||
|
"reason": "Existing instance must restart to adopt shared profile mounts",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
@@ -140,8 +170,47 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
profile = await update_profile(session, profile, data)
|
profile = await update_profile(session, profile, data)
|
||||||
|
response = profile_to_response(profile)
|
||||||
|
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return profile_to_response(profile)
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{profile_id}/refresh-git-mounts")
|
||||||
|
async def refresh_profile_git_mounts(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Refresh canonical Git mount sources used by running profile instances."""
|
||||||
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||||
|
for outcome in outcomes:
|
||||||
|
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
|
||||||
|
if (
|
||||||
|
instance is None
|
||||||
|
or not instance.compose_path
|
||||||
|
or instance.selected_config_profile_id is None
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
await resolve_git_mounts(
|
||||||
|
session,
|
||||||
|
resolved,
|
||||||
|
os.path.dirname(instance.compose_path),
|
||||||
|
)
|
||||||
|
outcome["status"] = "refreshed"
|
||||||
|
outcome["reason"] = "Canonical Git mount source refreshed in place"
|
||||||
|
return {"refresh_outcomes": outcomes}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -161,6 +230,16 @@ async def delete_config_profile(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||||
|
if outcomes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Profile is still used by running instances",
|
||||||
|
"outcomes": outcomes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from asyncio import QueueFull
|
||||||
|
from json import JSONDecodeError
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
@@ -31,6 +33,9 @@ from src.services.terminal.terminal_manager import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_PENDING_INPUT_MESSAGES = 64
|
||||||
|
MAX_TERMINAL_INPUT_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class SessionRef:
|
class SessionRef:
|
||||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
@@ -86,9 +91,7 @@ async def _resolve_container_user(
|
|||||||
if not tool_type.manifest_id:
|
if not tool_type.manifest_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
manifest_def = await db_session.get(
|
manifest_def = await db_session.get(ToolDefinitionManifest, tool_type.manifest_id)
|
||||||
ToolDefinitionManifest, tool_type.manifest_id
|
|
||||||
)
|
|
||||||
if not manifest_def:
|
if not manifest_def:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -322,8 +325,38 @@ async def _handle_terminal_websocket(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _input_write_loop(input_queue: asyncio.Queue[tuple[object, bytes]]) -> None:
|
||||||
|
"""Serialize PTY writes without blocking terminal control messages."""
|
||||||
|
while True:
|
||||||
|
session, data = await input_queue.get()
|
||||||
|
try:
|
||||||
|
await session.write_input(data) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Terminal input write failed", exc_info=True)
|
||||||
|
finally:
|
||||||
|
input_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_terminal_input(
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]], session: object, data: bytes
|
||||||
|
) -> bool:
|
||||||
|
"""Queue bounded terminal input without blocking control-message processing."""
|
||||||
|
if len(data) > MAX_TERMINAL_INPUT_BYTES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
input_queue.put_nowait((session, data))
|
||||||
|
except QueueFull:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Receive terminal messages while a dedicated worker serializes PTY input."""
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(
|
||||||
|
maxsize=MAX_PENDING_INPUT_MESSAGES
|
||||||
|
)
|
||||||
|
input_writer = asyncio.create_task(_input_write_loop(input_queue))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
session = session_ref.session
|
session = session_ref.session
|
||||||
@@ -333,15 +366,44 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
message = await websocket.receive()
|
message = await websocket.receive()
|
||||||
if message["type"] == "websocket.receive":
|
if message["type"] == "websocket.receive":
|
||||||
if "bytes" in message:
|
if "bytes" in message:
|
||||||
await session.write_input(message["bytes"])
|
if not _queue_terminal_input(
|
||||||
|
input_queue, session, message["bytes"]
|
||||||
|
):
|
||||||
|
logger.warning("Terminal input buffer exceeded for %s", instance_id)
|
||||||
|
await websocket.close(code=1009, reason="Terminal input buffer full")
|
||||||
|
break
|
||||||
elif "text" in message:
|
elif "text" in message:
|
||||||
text = message["text"]
|
text = message["text"]
|
||||||
|
# A text frame that parses to a JSON object with a
|
||||||
|
# "type" field is a control message and must NEVER be
|
||||||
|
# written to the PTY (e.g. the heartbeat {"type":"pong"}
|
||||||
|
# must be consumed, not typed into the shell/pi). Handle
|
||||||
|
# known types and ignore unknown ones. Everything else
|
||||||
|
# (keystrokes, bracketed-paste content, plain text) is
|
||||||
|
# forwarded as raw terminal input.
|
||||||
|
ctrl = None
|
||||||
if text.startswith("{"):
|
if text.startswith("{"):
|
||||||
# Control message (JSON)
|
|
||||||
try:
|
try:
|
||||||
ctrl = json.loads(text)
|
parsed = json.loads(text)
|
||||||
msg_type = ctrl.get("type")
|
except JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, dict) and "type" in parsed:
|
||||||
|
ctrl = parsed
|
||||||
|
|
||||||
|
if ctrl is None:
|
||||||
|
if not _queue_terminal_input(
|
||||||
|
input_queue, session, text.encode("utf-8")
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Terminal input buffer exceeded for %s", instance_id
|
||||||
|
)
|
||||||
|
await websocket.close(
|
||||||
|
code=1009, reason="Terminal input buffer full"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_type = ctrl["type"]
|
||||||
if msg_type == "resize":
|
if msg_type == "resize":
|
||||||
cols = ctrl.get("cols", 80)
|
cols = ctrl.get("cols", 80)
|
||||||
rows = ctrl.get("rows", 24)
|
rows = ctrl.get("rows", 24)
|
||||||
@@ -381,25 +443,23 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
session_ref.session = new_session
|
session_ref.session = new_session
|
||||||
|
|
||||||
# Attach to new session
|
# Attach to new session
|
||||||
await terminal_manager.attach_websocket(
|
await terminal_manager.attach_websocket(new_session, websocket)
|
||||||
new_session, websocket
|
|
||||||
)
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "status", "status": "connected"}
|
{"type": "status", "status": "connected"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Continue the loop with the new session
|
# Continue the loop with the new session
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Not a valid JSON control message, treat as regular input
|
|
||||||
await session.write_input(text.encode("utf-8"))
|
|
||||||
else:
|
|
||||||
await session.write_input(text.encode("utf-8"))
|
|
||||||
elif message["type"] == "websocket.disconnect":
|
elif message["type"] == "websocket.disconnect":
|
||||||
break
|
break
|
||||||
except Exception:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
except (RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
logger.debug("Terminal WebSocket receive loop ended: %s", exc)
|
||||||
|
finally:
|
||||||
|
input_writer.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await input_writer
|
||||||
|
|
||||||
|
|
||||||
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileRefreshOutcome(BaseModel):
|
||||||
|
instance_id: str
|
||||||
|
status: str
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
class ConfigProfileResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
|
|||||||
includes: list[dict]
|
includes: list[dict]
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
class DefaultProfilesUpdate(BaseModel):
|
||||||
|
|||||||
@@ -5,47 +5,17 @@ import logging
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models import ToolType
|
from src.models import ToolDefinitionManifest, ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# All supported built-in tools run their long-lived process with these IDs.
|
||||||
|
# Canonical writable Config Profile mounts can therefore be shared without
|
||||||
|
# per-instance ownership changes.
|
||||||
|
BUILTIN_USER_UID = 1000
|
||||||
|
BUILTIN_USER_GID = 1000
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
BUILTIN_TOOL_TYPES = [
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
"""Create or update built-in tool types in the database.
|
|
||||||
|
|
||||||
Built-in tool types have no creator (created_by_id=None) and provide
|
|
||||||
out-of-the-box tools for users without requiring manual tool creation.
|
|
||||||
"""
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
{
|
||||||
"name": "code-server",
|
"name": "code-server",
|
||||||
"display_name": "VS Code Server",
|
"display_name": "VS Code Server",
|
||||||
@@ -83,6 +53,8 @@ services:
|
|||||||
container_name: {{TOOL_NAME}}
|
container_name: {{TOOL_NAME}}
|
||||||
environment:
|
environment:
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
- JUPYTER_ENABLE_LAB=yes
|
||||||
|
- NB_UID=1000
|
||||||
|
- NB_GID=1000
|
||||||
volumes:
|
volumes:
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
- {{REPO_PATH}}:/home/jovyan/work
|
||||||
ports:
|
ports:
|
||||||
@@ -102,31 +74,95 @@ services:
|
|||||||
opencode:
|
opencode:
|
||||||
image: node:20-slim
|
image: node:20-slim
|
||||||
container_name: {{TOOL_NAME}}
|
container_name: {{TOOL_NAME}}
|
||||||
working_dir: /home/user/{{WORKSPACE_NAME}}
|
working_dir: /home/node/{{WORKSPACE_NAME}}
|
||||||
volumes:
|
volumes:
|
||||||
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}}
|
- {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
command: >
|
command: >
|
||||||
sh -c "set -x &&
|
sh -ec "apt-get update && apt-get install -y git ca-certificates &&
|
||||||
apt-get update && apt-get install -y git ca-certificates &&
|
npm install -g opencode-ai &&
|
||||||
echo 'Installing opencode...' &&
|
exec setpriv --reuid=node --regid=node --init-groups opencode server"
|
||||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
||||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
||||||
npm bin -g &&
|
|
||||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
||||||
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
|
||||||
echo 'cd /home/user/{{WORKSPACE_NAME}}' >> /root/.bashrc &&
|
|
||||||
echo 'OpenCode installation complete' &&
|
|
||||||
exec tail -f /dev/null"
|
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
restart: 'no'""",
|
restart: 'no'""",
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
|
||||||
|
def _standardize_builtin_manifest_user(manifest: dict) -> bool:
|
||||||
|
"""Set the built-in manifest user to the shared UID/GID in place.
|
||||||
|
|
||||||
|
The helper deliberately recognizes only Headquarter's conventional
|
||||||
|
``user`` account so it cannot rewrite a future custom tool definition.
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if not isinstance(user, dict) or user.get("name") != "user":
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = user.get("uid") != BUILTIN_USER_UID or user.get("gid") != BUILTIN_USER_GID
|
||||||
|
if changed:
|
||||||
|
user["uid"] = BUILTIN_USER_UID
|
||||||
|
user["gid"] = BUILTIN_USER_GID
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _standardize_pi_agent_manifest(session) -> None:
|
||||||
|
"""Bring the built-in Pi Agent manifest in line with shared mount IDs."""
|
||||||
|
manifest_definition = await session.scalar(
|
||||||
|
select(ToolDefinitionManifest).where(
|
||||||
|
ToolDefinitionManifest.name == "pi-agent",
|
||||||
|
ToolDefinitionManifest.created_by_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if manifest_definition is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest = dict(manifest_definition.manifest)
|
||||||
|
if _standardize_builtin_manifest_user(manifest):
|
||||||
|
manifest_definition.manifest = manifest
|
||||||
|
logger.info("Standardized built-in Pi Agent user to 1000:1000")
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_exists(session, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists in the database."""
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = :table_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() or False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_builtin_tool_types():
|
||||||
|
"""Create or update built-in tool types in the database.
|
||||||
|
|
||||||
|
Built-in tool types have no creator (created_by_id=None) and provide
|
||||||
|
out-of-the-box tools for users without requiring manual tool creation.
|
||||||
|
"""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
# Check if tool_types table exists before attempting to seed
|
||||||
|
if not await _table_exists(session, "tool_types"):
|
||||||
|
logger.warning(
|
||||||
|
"tool_types table does not exist. Skipping seeding. "
|
||||||
|
"Migrations may not have run yet."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _standardize_pi_agent_manifest(session)
|
||||||
|
|
||||||
|
for tool_data in BUILTIN_TOOL_TYPES:
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ToolType).where(ToolType.name == tool_data["name"])
|
select(ToolType).where(ToolType.name == tool_data["name"])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ and cycle protection.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -481,6 +482,7 @@ def apply_resolved_profile(
|
|||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
home_dir: str = "/root",
|
home_dir: str = "/root",
|
||||||
|
working_dir: str | None = None,
|
||||||
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
"""Apply a resolved profile to an instance directory.
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
@@ -489,6 +491,8 @@ def apply_resolved_profile(
|
|||||||
Args:
|
Args:
|
||||||
instance_dir: Path to the instance directory.
|
instance_dir: Path to the instance directory.
|
||||||
resolved: The resolved profile.
|
resolved: The resolved profile.
|
||||||
|
home_dir: Container home directory used for path expansion.
|
||||||
|
working_dir: Container working directory for top-level profile files.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
@@ -501,49 +505,57 @@ def apply_resolved_profile(
|
|||||||
|
|
||||||
instance_path = Path(instance_dir)
|
instance_path = Path(instance_dir)
|
||||||
env_vars = dict(resolved.env_vars)
|
env_vars = dict(resolved.env_vars)
|
||||||
files = dict(resolved.files)
|
working_dir = working_dir or home_dir
|
||||||
volume_mounts = []
|
volume_mounts = []
|
||||||
|
|
||||||
# Write profile files to instance directory
|
# Profile content belongs to the profile, not an individual tool instance.
|
||||||
for file_path, content in files.items():
|
# Keeping it beside the instance root gives every compatible instance the
|
||||||
full_path = instance_path / file_path
|
# same host source while retaining the existing instance storage setting.
|
||||||
try:
|
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
files_dir = profile_dir / "files"
|
||||||
except ValueError:
|
mounts_dir = profile_dir / "mounts"
|
||||||
logger.warning(
|
|
||||||
"Profile file path escapes instance directory: %s", file_path
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Stage mount directories and prepare directory-level volume mounts.
|
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
|
||||||
# Each ResolvedMount targets a container directory; we stage all of its
|
path = root / relative_path
|
||||||
# files under a single host directory and bind-mount that directory. This
|
try:
|
||||||
# keeps the target directory writable by the container user, instead of
|
path.resolve().relative_to(root.resolve())
|
||||||
# having Docker create a root-owned parent directory when only individual
|
except ValueError:
|
||||||
# files are mounted.
|
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
||||||
|
return None
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
||||||
|
) as temporary_file:
|
||||||
|
temporary_file.write(content)
|
||||||
|
temporary_path = Path(temporary_file.name)
|
||||||
|
temporary_path.replace(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Top-level profile files are individual bind mounts under the working
|
||||||
|
# directory. They therefore cannot mask the workspace directory itself.
|
||||||
|
for file_path, content in resolved.files.items():
|
||||||
|
canonical_file = write_canonical_file(files_dir, file_path, content)
|
||||||
|
if canonical_file is None:
|
||||||
|
continue
|
||||||
|
volume_mounts.append(
|
||||||
|
{
|
||||||
|
"source": str(canonical_file),
|
||||||
|
"target": os.path.normpath(os.path.join(working_dir, file_path)),
|
||||||
|
"type": "bind",
|
||||||
|
"readonly": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Explicit profile mounts remain directory-level bind mounts, but use the
|
||||||
|
# same profile-scoped canonical source for every instance.
|
||||||
for mount in resolved.mounts.values():
|
for mount in resolved.mounts.values():
|
||||||
if not mount.files:
|
if not mount.files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expanded_target = os.path.normpath(
|
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
|
||||||
expand_container_path(mount.target, home_dir)
|
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
|
||||||
)
|
|
||||||
mount_dir = (
|
|
||||||
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
|
|
||||||
)
|
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in mount.files.items():
|
for file_path, content in mount.files.items():
|
||||||
full_path = mount_dir / file_path
|
write_canonical_file(mount_dir, file_path, content)
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(mount_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
volume_mounts.append(
|
volume_mounts.append(
|
||||||
{
|
{
|
||||||
@@ -554,7 +566,9 @@ def apply_resolved_profile(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
# Files are now mounted directly from canonical storage, not copied into
|
||||||
|
# the instance directory for write_config_files().
|
||||||
|
return env_vars, {}, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
def expand_container_path(path: str, home_dir: str) -> str:
|
def expand_container_path(path: str, home_dir: str) -> str:
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import signal
|
|||||||
import struct
|
import struct
|
||||||
import fcntl
|
import fcntl
|
||||||
import time
|
import time
|
||||||
|
import tty
|
||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_session_counters_by_instance: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages a single terminal session with event-driven PTY I/O.
|
"""Manages a single terminal session with event-driven PTY I/O.
|
||||||
@@ -45,9 +48,6 @@ class TerminalSession:
|
|||||||
# Max WebSocket frame size
|
# Max WebSocket frame size
|
||||||
MAX_FRAME_SIZE = 64 * 1024
|
MAX_FRAME_SIZE = 64 * 1024
|
||||||
|
|
||||||
# Session number counter per instance_id for auto-naming
|
|
||||||
_instance_counters: dict[str, int] = {}
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -98,11 +98,11 @@ class TerminalSession:
|
|||||||
# Ack timeout fallback
|
# Ack timeout fallback
|
||||||
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def _generate_name(cls, instance_id: str) -> str:
|
def _generate_name(instance_id: str) -> str:
|
||||||
"""Generate an auto-incremented session name for the instance."""
|
"""Generate an auto-incremented session name for the instance."""
|
||||||
count = cls._instance_counters.get(instance_id, 0) + 1
|
count = _session_counters_by_instance.get(instance_id, 0) + 1
|
||||||
cls._instance_counters[instance_id] = count
|
_session_counters_by_instance[instance_id] = count
|
||||||
return f"Session {count}"
|
return f"Session {count}"
|
||||||
|
|
||||||
async def start(self, startup_command: str | None = None) -> None:
|
async def start(self, startup_command: str | None = None) -> None:
|
||||||
@@ -120,8 +120,21 @@ class TerminalSession:
|
|||||||
if self.container_user:
|
if self.container_user:
|
||||||
exec_cmd.extend(["--user", self.container_user])
|
exec_cmd.extend(["--user", self.container_user])
|
||||||
|
|
||||||
# Create a pseudo-terminal on the host
|
# Create a pseudo-terminal on the host. The master must be
|
||||||
|
# non-blocking: a browser paste can be larger than the PTY input
|
||||||
|
# buffer, and write_input() drains it asynchronously without dropping
|
||||||
|
# the closing bracketed-paste marker.
|
||||||
self._master_fd, slave_fd = pty.openpty()
|
self._master_fd, slave_fd = pty.openpty()
|
||||||
|
os.set_blocking(self._master_fd, False)
|
||||||
|
|
||||||
|
# Put the host PTY into raw mode so it behaves as a pass-through
|
||||||
|
# pipe. openpty() leaves the slave in canonical mode by default,
|
||||||
|
# which line-buffers input, splits multiline pastes at newlines,
|
||||||
|
# and mangles bracketed-paste markers before docker exec / the
|
||||||
|
# foreground app (e.g. pi) ever see them. Real terminal discipline
|
||||||
|
# (echo, canonical editing for readline) is provided by the
|
||||||
|
# in-container PTY that `docker exec -t` allocates.
|
||||||
|
tty.setraw(slave_fd)
|
||||||
|
|
||||||
# Set the terminal size initially
|
# Set the terminal size initially
|
||||||
self._set_terminal_size(self._cols, self._rows)
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
@@ -194,6 +207,9 @@ class TerminalSession:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = os.read(self._master_fd, 4096)
|
data = os.read(self._master_fd, 4096)
|
||||||
|
except BlockingIOError:
|
||||||
|
# The readiness notification raced with another callback.
|
||||||
|
return
|
||||||
except (OSError, IOError) as exc:
|
except (OSError, IOError) as exc:
|
||||||
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||||
self._handle_eof()
|
self._handle_eof()
|
||||||
@@ -333,12 +349,42 @@ class TerminalSession:
|
|||||||
pass
|
pass
|
||||||
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||||
|
|
||||||
|
async def _wait_for_write_ready(self, fd: int) -> None:
|
||||||
|
"""Wait until a non-blocking PTY master can accept more input."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
writable = loop.create_future()
|
||||||
|
|
||||||
|
def mark_writable() -> None:
|
||||||
|
if not writable.done():
|
||||||
|
writable.set_result(None)
|
||||||
|
|
||||||
|
loop.add_writer(fd, mark_writable)
|
||||||
|
try:
|
||||||
|
await writable
|
||||||
|
finally:
|
||||||
|
loop.remove_writer(fd)
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the PTY master."""
|
"""Write all terminal input bytes to the PTY master in order."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
fd = self._master_fd
|
||||||
|
remaining = memoryview(data)
|
||||||
try:
|
try:
|
||||||
os.write(self._master_fd, data)
|
while remaining and not self._closed and self._master_fd == fd:
|
||||||
|
try:
|
||||||
|
written = os.write(fd, remaining)
|
||||||
|
except BlockingIOError:
|
||||||
|
await self._wait_for_write_ready(fd)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if written == 0:
|
||||||
|
await self._wait_for_write_ready(fd)
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining = remaining[written:]
|
||||||
|
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
except (OSError, IOError) as exc:
|
except (OSError, IOError) as exc:
|
||||||
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import fcntl
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -158,57 +160,23 @@ def _stack_profile_mounts_with_git_mounts(
|
|||||||
profile_mounts: list[dict],
|
profile_mounts: list[dict],
|
||||||
git_mount_volumes: list[dict],
|
git_mount_volumes: list[dict],
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Merge profile file mounts into overlapping git-mount sources.
|
"""Leave profile and Git sources isolated.
|
||||||
|
|
||||||
When a config profile mounts static files to the same directory as a
|
Git mount sources are shared, read-only canonical checkouts. Profile
|
||||||
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
|
content must never be copied into one because doing so dirties the
|
||||||
would mask the cloned repository. Instead, copy the profile files into
|
checkout and leaks one profile's content to every instance using it.
|
||||||
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 profile_mount in profile_mounts:
|
||||||
for pvol in profile_mounts:
|
profile_target = profile_mount.get("target", "")
|
||||||
p_source = pvol.get("source", "")
|
for git_mount in git_mount_volumes:
|
||||||
p_target = pvol.get("target", "")
|
if _relative_under(git_mount.get("target", ""), profile_target) is not None:
|
||||||
if not p_source or not os.path.exists(p_source):
|
logger.warning(
|
||||||
remaining.append(pvol)
|
"Config profile mount %s overlaps Git mount %s; keeping sources isolated",
|
||||||
continue
|
profile_target,
|
||||||
|
git_mount.get("target"),
|
||||||
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
|
break
|
||||||
|
return profile_mounts
|
||||||
if not merged:
|
|
||||||
remaining.append(pvol)
|
|
||||||
|
|
||||||
return remaining
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_git_mounts(
|
async def resolve_git_mounts(
|
||||||
@@ -227,12 +195,18 @@ async def resolve_git_mounts(
|
|||||||
if not resolved.git_mounts:
|
if not resolved.git_mounts:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Git mount sources are profile-scoped, not instance-scoped, so compatible
|
||||||
|
# instances bind the same canonical checkout.
|
||||||
|
clone_parent = os.path.join(
|
||||||
|
os.path.dirname(instance_dir or ""), "config-profiles", str(resolved.profile_id)
|
||||||
|
)
|
||||||
|
|
||||||
# Process all git mounts concurrently
|
# Process all git mounts concurrently
|
||||||
tasks = []
|
tasks = []
|
||||||
for git_mount in resolved.git_mounts:
|
for git_mount in resolved.git_mounts:
|
||||||
tasks.append(
|
tasks.append(
|
||||||
resolve_single_git_mount(
|
resolve_single_git_mount(
|
||||||
session, git_mount, instance_dir, working_directory, home_dir
|
session, git_mount, clone_parent, working_directory, home_dir
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,6 +239,37 @@ def normalize_git_mount(entry: dict) -> dict:
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _git_mount_lock(clone_parent: str, remote_url: str, branch: str | None):
|
||||||
|
"""Serialize clone and refresh operations for one canonical Git source."""
|
||||||
|
lock_dir = os.path.join(clone_parent, "git-mounts")
|
||||||
|
identity = f"{remote_url}:{branch or 'default'}"
|
||||||
|
lock_path = os.path.join(
|
||||||
|
lock_dir, f".{hashlib.sha256(identity.encode()).hexdigest()}.lock"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(lock_dir, exist_ok=True)
|
||||||
|
with open(lock_path, "a+", encoding="utf-8") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot lock Git mount source: {lock_path}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def clone_git_repo_locked(
|
||||||
|
remote_url: str,
|
||||||
|
branch: str | None,
|
||||||
|
clone_parent: str,
|
||||||
|
project_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Clone or refresh a canonical source while holding its process lock."""
|
||||||
|
with _git_mount_lock(clone_parent, remote_url, branch):
|
||||||
|
return clone_git_repo(remote_url, branch, clone_parent, project_name)
|
||||||
|
|
||||||
|
|
||||||
def clone_git_repo(
|
def clone_git_repo(
|
||||||
remote_url: str,
|
remote_url: str,
|
||||||
branch: str | None,
|
branch: str | None,
|
||||||
@@ -283,16 +288,27 @@ def clone_git_repo(
|
|||||||
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
|
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
|
||||||
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
||||||
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
||||||
repo_path = clone_repository(
|
clone_name = _slugify_directory_name(project_name) if project_name else "repo-clone"
|
||||||
remote_url,
|
repo_path = os.path.join(clone_dir, clone_name)
|
||||||
None, # No SSH key for now - can be added later
|
|
||||||
clone_dir,
|
|
||||||
branch or "main",
|
|
||||||
project_name=project_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.exists(repo_path):
|
if os.path.isdir(os.path.join(repo_path, ".git")):
|
||||||
|
# Reuse the deterministic per-repository cache on repeated starts.
|
||||||
try:
|
try:
|
||||||
|
pull_repository_updates(repo_path, remote_url)
|
||||||
|
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# A failed clone can leave its destination behind. Remove only the
|
||||||
|
# computed clone path so the next start can retry cleanly.
|
||||||
|
if os.path.lexists(repo_path):
|
||||||
|
logger.warning("Removing incomplete git mount clone at %s", repo_path)
|
||||||
|
if os.path.isdir(repo_path) and not os.path.islink(repo_path):
|
||||||
|
shutil.rmtree(repo_path)
|
||||||
|
else:
|
||||||
|
os.unlink(repo_path)
|
||||||
|
|
||||||
os.makedirs(clone_dir, exist_ok=True)
|
os.makedirs(clone_dir, exist_ok=True)
|
||||||
repo_path = clone_repository(
|
repo_path = clone_repository(
|
||||||
remote_url,
|
remote_url,
|
||||||
@@ -305,13 +321,6 @@ def clone_git_repo(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
||||||
raise
|
raise
|
||||||
else:
|
|
||||||
# Repo exists - pull latest updates
|
|
||||||
try:
|
|
||||||
pull_repository_updates(repo_path, remote_url)
|
|
||||||
logger.debug("Pulled updates for git mount %s", remote_url)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
|
||||||
|
|
||||||
# Handle branch checkout if specified
|
# Handle branch checkout if specified
|
||||||
if branch and repo_path:
|
if branch and repo_path:
|
||||||
@@ -421,7 +430,7 @@ def resolve_git_mount_mappings(
|
|||||||
async def resolve_single_git_mount(
|
async def resolve_single_git_mount(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
git_mount: dict,
|
git_mount: dict,
|
||||||
instance_dir: str | None = None,
|
clone_parent: str | None = None,
|
||||||
working_directory: str | None = None,
|
working_directory: str | None = None,
|
||||||
home_dir: str = "/root",
|
home_dir: str = "/root",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -443,15 +452,15 @@ async def resolve_single_git_mount(
|
|||||||
logger.warning("Invalid git mount skipped: no mappings")
|
logger.warning("Invalid git mount skipped: no mappings")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not instance_dir:
|
if not clone_parent:
|
||||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
logger.warning("Git mount skipped: no canonical profile directory provided")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Clone or pull the repository. Git mounts are auxiliary, so they keep
|
# Clone or pull the repository. Git mounts are auxiliary, so they keep
|
||||||
# using the repository URL basename rather than the project name.
|
# using the repository URL basename rather than the project name.
|
||||||
try:
|
try:
|
||||||
repo_path = await asyncio.to_thread(
|
repo_path = await asyncio.to_thread(
|
||||||
clone_git_repo, remote_url, branch, instance_dir
|
clone_git_repo_locked, remote_url, branch, clone_parent
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -464,7 +473,12 @@ async def resolve_single_git_mount(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Resolve all mappings from the cloned repo
|
# Resolve all mappings from the cloned repo
|
||||||
return resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
|
volumes = resolve_git_mount_mappings(
|
||||||
|
repo_path, mappings, working_directory, home_dir
|
||||||
|
)
|
||||||
|
for volume in volumes:
|
||||||
|
volume["readonly"] = True
|
||||||
|
return volumes
|
||||||
|
|
||||||
|
|
||||||
def checkout_branch(repo_path: str, branch: str) -> bool:
|
def checkout_branch(repo_path: str, branch: str) -> bool:
|
||||||
@@ -1398,17 +1412,22 @@ async def start_tool_instance(
|
|||||||
resolved = await resolve_profile(
|
resolved = await resolve_profile(
|
||||||
session, instance.selected_config_profile_id
|
session, instance.selected_config_profile_id
|
||||||
)
|
)
|
||||||
|
# Profile working-directory hints determine where individual
|
||||||
|
# canonical profile files are bind-mounted at container creation.
|
||||||
|
profile_hints = resolved.runtime_hints
|
||||||
|
if profile_hints.get("working_directory"):
|
||||||
|
working_directory = expand_container_path(
|
||||||
|
profile_hints["working_directory"], home_dir
|
||||||
|
)
|
||||||
profile_env, profile_files, profile_mounts, profile_hints = (
|
profile_env, profile_files, profile_mounts, profile_hints = (
|
||||||
apply_resolved_profile(instance_dir, resolved, home_dir)
|
apply_resolved_profile(
|
||||||
|
instance_dir, resolved, home_dir, working_directory
|
||||||
|
)
|
||||||
)
|
)
|
||||||
# Profile hints override the manifest/tool defaults, and git mounts
|
# Profile hints override the manifest/tool defaults, and git mounts
|
||||||
# need the final working directory to resolve relative target paths.
|
# need the final working directory to resolve relative target paths.
|
||||||
if profile_hints.get("start_command"):
|
if profile_hints.get("start_command"):
|
||||||
start_command = profile_hints["start_command"]
|
start_command = profile_hints["start_command"]
|
||||||
if profile_hints.get("working_directory"):
|
|
||||||
working_directory = expand_container_path(
|
|
||||||
profile_hints["working_directory"], home_dir
|
|
||||||
)
|
|
||||||
if profile_hints.get("port_override"):
|
if profile_hints.get("port_override"):
|
||||||
port_override = profile_hints["port_override"]
|
port_override = profile_hints["port_override"]
|
||||||
env_vars.update(profile_env)
|
env_vars.update(profile_env)
|
||||||
@@ -1472,7 +1491,15 @@ async def start_tool_instance(
|
|||||||
|
|
||||||
if ssh_keys_to_mount:
|
if ssh_keys_to_mount:
|
||||||
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
|
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
|
||||||
|
try:
|
||||||
os.makedirs(ssh_dir, exist_ok=True)
|
os.makedirs(ssh_dir, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to create SSH mount directory for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
ssh_keys_to_mount = []
|
||||||
|
|
||||||
key_filenames = []
|
key_filenames = []
|
||||||
for ssh_key in ssh_keys_to_mount:
|
for ssh_key in ssh_keys_to_mount:
|
||||||
@@ -2185,7 +2212,13 @@ async def delete_tool_instance(
|
|||||||
if os.path.exists(instance_dir):
|
if os.path.exists(instance_dir):
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
try:
|
||||||
shutil.rmtree(instance_dir)
|
shutil.rmtree(instance_dir)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to remove instance directory %s: %s", instance_dir, exc
|
||||||
|
)
|
||||||
|
raise RuntimeError("Failed to remove instance files") from exc
|
||||||
|
|
||||||
await publish_lifecycle_event(
|
await publish_lifecycle_event(
|
||||||
event_bus=_event_bus,
|
event_bus=_event_bus,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/tests
|
dir: apps/api/tests
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides shared pytest fixtures and test utilities for FastAPI application testing across the API test suite.
|
Provides shared pytest fixtures and test utilities for FastAPI application testing
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/.pi-map.index.md
|
index: apps/api/.pi-map.index.md
|
||||||
map: apps/api/.pi-map.md
|
map: apps/api/.pi-map.md
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ dir: apps/api/tests
|
|||||||
index: apps/api/tests/.pi-map.index.md
|
index: apps/api/tests/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides shared pytest fixtures and test utilities for FastAPI application testing across the API test suite.
|
Provides shared pytest fixtures and test utilities for FastAPI application testing
|
||||||
## files
|
## files
|
||||||
- conftest.py | Provides shared pytest fixtures including test clients, database sessions, authenticated clients, and test data for FastAPI application testing. | 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.testclient.TestClient
|
- conftest.py | Provides shared pytest fixtures including test clients, database sessions, authenticated clients, and test data for FastAPI application testing. | 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.testclient.TestClient
|
||||||
## arch
|
## arch
|
||||||
Standard pytest fixture pattern with dependency injection for test clients, database sessions, and authentication state management.
|
Pytest plugin pattern with dependency injection fixtures for database sessions, HTTP clients, and authentication state management
|
||||||
## tags
|
## tags
|
||||||
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
|
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from src.api.system.terminal import (
|
||||||
|
MAX_TERMINAL_INPUT_BYTES,
|
||||||
|
SessionRef,
|
||||||
|
_queue_terminal_input,
|
||||||
|
_write_loop,
|
||||||
|
)
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
@@ -19,12 +27,79 @@ class TestTerminalWebSocketMultiSession:
|
|||||||
# the route exists by checking for a 403 (no auth cookie)
|
# the route exists by checking for a 403 (no auth cookie)
|
||||||
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
||||||
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
||||||
assert response.status_code in (403, 404)
|
assert response.status_code == 403 or response.status_code == 404
|
||||||
|
|
||||||
def test_default_session_alias_route_exists(self, client):
|
def test_default_session_alias_route_exists(self, client):
|
||||||
"""The default session alias route should still exist."""
|
"""The default session alias route should still exist."""
|
||||||
response = client.get("/ws/tool-instances/test-instance/terminal")
|
response = client.get("/ws/tool-instances/test-instance/terminal")
|
||||||
assert response.status_code in (403, 404)
|
assert response.status_code == 403 or response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_input_queue_rejects_excess_input_without_blocking() -> None:
|
||||||
|
"""A stalled PTY writer cannot make the input queue grow without limit."""
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(maxsize=1)
|
||||||
|
session = object()
|
||||||
|
|
||||||
|
assert _queue_terminal_input(input_queue, session, b"first")
|
||||||
|
assert not _queue_terminal_input(input_queue, session, b"second")
|
||||||
|
assert not _queue_terminal_input(
|
||||||
|
asyncio.Queue(), session, b"x" * (MAX_TERMINAL_INPUT_BYTES + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ack_is_processed_while_a_pty_write_is_waiting() -> None:
|
||||||
|
"""A blocked paste writer must not block flow-control acknowledgements."""
|
||||||
|
|
||||||
|
write_started = asyncio.Event()
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
_closed = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.acks: list[int] = []
|
||||||
|
self.write_finished = False
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def write_input(self, _data: bytes) -> None:
|
||||||
|
write_started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
self.write_finished = True
|
||||||
|
|
||||||
|
def acknowledge_data(self, char_count: int) -> None:
|
||||||
|
self.acks.append(char_count)
|
||||||
|
|
||||||
|
class WebSocket:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages = iter(
|
||||||
|
[
|
||||||
|
{"type": "websocket.receive", "bytes": b"large paste"},
|
||||||
|
{"type": "websocket.receive", "text": '{"type":"ack","chars":4096}'},
|
||||||
|
{"type": "websocket.disconnect"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.receive_count = 0
|
||||||
|
|
||||||
|
async def receive(self):
|
||||||
|
self.receive_count += 1
|
||||||
|
if self.receive_count > 1:
|
||||||
|
await write_started.wait()
|
||||||
|
return next(self.messages)
|
||||||
|
|
||||||
|
session = Session()
|
||||||
|
websocket = WebSocket()
|
||||||
|
task = asyncio.create_task(_write_loop(SessionRef(session), websocket, "instance"))
|
||||||
|
|
||||||
|
await asyncio.wait_for(write_started.wait(), timeout=0.1)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert session.acks == [4096]
|
||||||
|
|
||||||
|
await task
|
||||||
|
assert session.write_finished
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalRestApi:
|
class TestTerminalRestApi:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/api/tests/unit
|
dir: apps/api/tests/unit
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Unit test suite for the API backend, covering configuration, Docker services, Git operations, event bus, file/SSH/terminal utilities, monitoring, notifications, and manifest compilation.
|
Contains isolated unit tests for individual components of the API application, covering configuration, services, utilities, and core business logic without external dependencies.
|
||||||
## parent
|
## parent
|
||||||
index: apps/api/tests/.pi-map.index.md
|
index: apps/api/tests/.pi-map.index.md
|
||||||
map: apps/api/tests/.pi-map.md
|
map: apps/api/tests/.pi-map.md
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ dir: apps/api/tests/unit
|
|||||||
index: apps/api/tests/unit/.pi-map.index.md
|
index: apps/api/tests/unit/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Unit test suite for the API backend, covering configuration, Docker services, Git operations, event bus, file/SSH/terminal utilities, monitoring, notifications, and manifest compilation.
|
Contains isolated unit tests for individual components of the API application, covering configuration, services, utilities, and core business logic without external dependencies.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Empty package initialization file that marks a directory as a Python package.
|
- __init__.py | Empty package initialization file that marks a directory as a Python package.
|
||||||
- test_alembic_migrations.py | Verifies the structure, importability, and metadata of specific Alembic migration files without requiring a database connection. | 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, pathlib.Path
|
- test_alembic_migrations.py | Unit tests that verify Alembic database migration files 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, func:test_remove_pi_agent_workspace_symlink_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, pathlib.Path
|
||||||
- test_config.py | Unit tests verifying configuration settings including database URLs, auth defaults, and environment-specific cookie policies. | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database, src.config.Settings, src.database.build_database_url
|
- test_config.py | Unit tests verifying configuration settings including database URLs, auth defaults, and environment-specific cookie policies. | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database, src.config.Settings, src.database.build_database_url
|
||||||
- test_config_profile_resolver.py | Tests config profile resolution logic including merge functions, profile inheritance with includes, 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, pathlib.Path, sqlalchemy.ext.asyncio.AsyncSession
|
- test_config_profile_resolver.py | Tests config profile resolution logic including merge functions, profile inheritance with includes, 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, pathlib.Path, sqlalchemy.ext.asyncio.AsyncSession
|
||||||
- test_docker_build.py | Unit tests for the `build_image` Docker build service function, verifying successful builds, error handling, file writing, and security. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build, pathlib.Path
|
- test_docker_build.py | Unit tests for the `build_image` Docker build service function, verifying successful builds, error handling, file writing, and security. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build, pathlib.Path
|
||||||
@@ -32,7 +32,7 @@ Unit test suite for the API backend, covering configuration, Docker services, Gi
|
|||||||
- test_terminal_container_user.py | Unit tests for resolving container user from tool type definitions, handling legacy tools, manifest-based tools, base definition inheritance, and missing definitions. | exp: func:test_resolve_container_user_legacy_tool_type(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, call:session.get.assert_awaited_once, func:test_resolve_container_user_manifest_no_manifest_id(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:test_resolve_container_user_manifest_with_user(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:session_get(model, obj_id), func:test_resolve_container_user_manifest_with_base_definition(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:session_get(model, obj_id), func:test_resolve_container_user_missing_manifest_definition(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:session_get(model, obj_id) | dep: unittest.mock, pytest, src.api.system.terminal, src.api.system.terminal._resolve_container_user
|
- test_terminal_container_user.py | Unit tests for resolving container user from tool type definitions, handling legacy tools, manifest-based tools, base definition inheritance, and missing definitions. | exp: func:test_resolve_container_user_legacy_tool_type(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, call:session.get.assert_awaited_once, func:test_resolve_container_user_manifest_no_manifest_id(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:test_resolve_container_user_manifest_with_user(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:session_get(model, obj_id), func:test_resolve_container_user_manifest_with_base_definition(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:session_get(model, obj_id), func:test_resolve_container_user_missing_manifest_definition(), call:MagicMock, call:AsyncMock, call:_resolve_container_user, func:session_get(model, obj_id) | dep: unittest.mock, pytest, src.api.system.terminal, src.api.system.terminal._resolve_container_user
|
||||||
- test_terminal_session.py | Unit tests verifying TerminalSession passes container_user to docker exec --user flag when set, and omits it when not configured. | exp: func:test_start_passes_container_user_to_docker_exec() → None, call:TerminalSession, call:str, call:uuid.uuid4, call:patch, call:AsyncMock, call:session.start, call:args.index, func:test_start_omits_user_when_not_configured() → None, call:TerminalSession, call:str, call:uuid.uuid4, call:patch, call:AsyncMock, call:session.start | dep: uuid, unittest.mock, pytest, src.services.terminal.terminal_session
|
- test_terminal_session.py | Unit tests verifying TerminalSession passes container_user to docker exec --user flag when set, and omits it when not configured. | exp: func:test_start_passes_container_user_to_docker_exec() → None, call:TerminalSession, call:str, call:uuid.uuid4, call:patch, call:AsyncMock, call:session.start, call:args.index, func:test_start_omits_user_when_not_configured() → None, call:TerminalSession, call:str, call:uuid.uuid4, call:patch, call:AsyncMock, call:session.start | dep: uuid, unittest.mock, pytest, src.services.terminal.terminal_session
|
||||||
## arch
|
## arch
|
||||||
pytest-based unit testing with fixtures for temporary resources (git repos, Docker mocks), heavy use of mocking for external dependencies (Docker, databases), and parameterized tests for cross-platform/git-hosting scenarios.
|
Standard Python unittest/pytest package structure with test files mirroring the source codebase; uses dependency injection, mocking (likely unittest.mock or pytest-mock), and temporary filesystem/git fixtures to test components in isolation.
|
||||||
## tags
|
## tags
|
||||||
test, url, call:, git, user, call:notification, container, mounts
|
test, url, call:, git, user, call:notification, container, mounts
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -67,3 +67,23 @@ def test_remove_pi_agent_repo_mount_migration_imports() -> None:
|
|||||||
assert module.down_revision == "2026_06_14_182955"
|
assert module.down_revision == "2026_06_14_182955"
|
||||||
assert callable(module.upgrade)
|
assert callable(module.upgrade)
|
||||||
assert callable(module.downgrade)
|
assert callable(module.downgrade)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_remove_pi_agent_workspace_symlink_migration_imports() -> None:
|
||||||
|
migration_path = Path(__file__).parent.parent.parent / (
|
||||||
|
"alembic/versions/2026_06_19_113000_remove_pi_agent_workspace_symlink.py"
|
||||||
|
)
|
||||||
|
assert migration_path.exists()
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"remove_workspace_symlink_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_19_113000"
|
||||||
|
assert module.down_revision == "2026_06_15_090500"
|
||||||
|
assert callable(module.upgrade)
|
||||||
|
assert callable(module.downgrade)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for the shared non-root user used by built-in tools."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.seeds.builtin_tool_types import (
|
||||||
|
BUILTIN_TOOL_TYPES,
|
||||||
|
BUILTIN_USER_GID,
|
||||||
|
BUILTIN_USER_UID,
|
||||||
|
_standardize_builtin_manifest_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
|
||||||
|
"""Every legacy built-in Compose tool declares the shared UID/GID."""
|
||||||
|
templates = {
|
||||||
|
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "- PUID=1000" in templates["code-server"]
|
||||||
|
assert "- PGID=1000" in templates["code-server"]
|
||||||
|
assert "- NB_UID=1000" in templates["jupyter-notebook"]
|
||||||
|
assert "- NB_GID=1000" in templates["jupyter-notebook"]
|
||||||
|
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_builtin_user_manifest_is_standardized() -> None:
|
||||||
|
"""The startup migration cannot rewrite a future custom tool user."""
|
||||||
|
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
|
||||||
|
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
|
||||||
|
|
||||||
|
assert _standardize_builtin_manifest_user(builtin_manifest)
|
||||||
|
assert builtin_manifest["user"] == {
|
||||||
|
"name": "user",
|
||||||
|
"uid": BUILTIN_USER_UID,
|
||||||
|
"gid": BUILTIN_USER_GID,
|
||||||
|
}
|
||||||
|
assert not _standardize_builtin_manifest_user(custom_manifest)
|
||||||
|
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_image_templates_define_shared_ids() -> None:
|
||||||
|
"""Project-owned image templates explicitly create or map UID/GID 1000."""
|
||||||
|
root = Path(__file__).resolve().parents[4]
|
||||||
|
sources = {
|
||||||
|
name: (root / "tool-images" / name).read_text()
|
||||||
|
for name in (
|
||||||
|
"base.dockerfile",
|
||||||
|
"opencode.dockerfile",
|
||||||
|
"pi-agent.dockerfile",
|
||||||
|
"code-server.dockerfile",
|
||||||
|
"jupyter.dockerfile",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
|
||||||
|
assert "groupadd -g 1000 user" in sources[name]
|
||||||
|
assert "useradd -m -u 1000 -g 1000" in sources[name]
|
||||||
|
|
||||||
|
assert "PUID=1000" in sources["code-server.dockerfile"]
|
||||||
|
assert "PGID=1000" in sources["code-server.dockerfile"]
|
||||||
|
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
|
||||||
|
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
|
||||||
@@ -532,6 +532,54 @@ class TestApplyResolvedProfile:
|
|||||||
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
||||||
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
||||||
|
|
||||||
|
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
|
||||||
|
"""Top-level files are shared safely without mounting over a workspace."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
files={".tool/config.toml": "setting = true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
_, files, volumes, _ = apply_resolved_profile(
|
||||||
|
str(instance_root / "instance-a"),
|
||||||
|
resolved,
|
||||||
|
working_dir="/workspace/project",
|
||||||
|
)
|
||||||
|
|
||||||
|
canonical_file = (
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
|
||||||
|
)
|
||||||
|
assert files == {}
|
||||||
|
assert volumes == [
|
||||||
|
{
|
||||||
|
"source": str(canonical_file),
|
||||||
|
"target": "/workspace/project/.tool/config.toml",
|
||||||
|
"type": "bind",
|
||||||
|
"readonly": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert canonical_file.read_text() == "setting = true"
|
||||||
|
|
||||||
|
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||||
|
"""Different instance paths resolve a profile to one canonical source."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
|
||||||
|
|
||||||
|
assert first_volumes[0]["source"] == second_volumes[0]["source"]
|
||||||
|
assert first_volumes[0]["source"] == str(
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||||
"""A mount with no files should not produce any volume entries."""
|
"""A mount with no files should not produce any volume entries."""
|
||||||
resolved = ResolvedProfile(
|
resolved = ResolvedProfile(
|
||||||
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
|
|||||||
|
|
||||||
assert len(volumes) == 1
|
assert len(volumes) == 1
|
||||||
assert volumes[0]["target"] == "/etc/app"
|
assert volumes[0]["target"] == "/etc/app"
|
||||||
assert volumes[0].get("readonly") is True
|
assert volumes[0].get("readonly")
|
||||||
|
|
||||||
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
||||||
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
||||||
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
|
|||||||
|
|
||||||
assert len(volumes) == 1
|
assert len(volumes) == 1
|
||||||
assert volumes[0]["target"] == "/app"
|
assert volumes[0]["target"] == "/app"
|
||||||
assert volumes[0].get("readonly") is False
|
assert not volumes[0].get("readonly")
|
||||||
|
|
||||||
|
|
||||||
class TestCheckIncludeCycle:
|
class TestCheckIncludeCycle:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for the tool instance service."""
|
"""Unit tests for the tool instance service."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import MagicMock, AsyncMock
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import pytest
|
|||||||
from src.services.tool.instance_service import (
|
from src.services.tool.instance_service import (
|
||||||
_get_repository_mount_name,
|
_get_repository_mount_name,
|
||||||
_stack_profile_mounts_with_git_mounts,
|
_stack_profile_mounts_with_git_mounts,
|
||||||
|
clone_git_repo,
|
||||||
modify_compose_file,
|
modify_compose_file,
|
||||||
prepare_manifest_instance,
|
prepare_manifest_instance,
|
||||||
)
|
)
|
||||||
@@ -67,14 +69,66 @@ class TestGetRepositoryMountName:
|
|||||||
assert _get_repository_mount_name(project, repo) == "project-v2-0"
|
assert _get_repository_mount_name(project, repo) == "project-v2-0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCloneGitRepo:
|
||||||
|
"""Regression tests for reusable config-profile git mount clones."""
|
||||||
|
|
||||||
|
def test_reuses_existing_clone(self, monkeypatch, tmp_path) -> None:
|
||||||
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
|
remote_url = "https://gitlab.com/example/dotfiles"
|
||||||
|
branch = None
|
||||||
|
clone_parent = str(tmp_path)
|
||||||
|
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
|
||||||
|
repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
|
||||||
|
(repo_path / ".git").mkdir(parents=True)
|
||||||
|
|
||||||
|
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
|
||||||
|
pull = MagicMock()
|
||||||
|
monkeypatch.setattr(instance_service, "clone_repository", clone)
|
||||||
|
monkeypatch.setattr(instance_service, "pull_repository_updates", pull)
|
||||||
|
|
||||||
|
result = clone_git_repo(remote_url, branch, clone_parent)
|
||||||
|
|
||||||
|
assert result == str(repo_path)
|
||||||
|
clone.assert_not_called()
|
||||||
|
pull.assert_called_once_with(str(repo_path), remote_url)
|
||||||
|
|
||||||
|
def test_replaces_incomplete_clone_before_retry(
|
||||||
|
self, monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
|
remote_url = "https://gitlab.com/example/dotfiles"
|
||||||
|
url_hash = hashlib.md5(f"{remote_url}:main".encode()).hexdigest()[:12]
|
||||||
|
clone_dir = tmp_path / "git-mounts" / f"dotfiles-{url_hash}"
|
||||||
|
repo_path = clone_dir / "repo-clone"
|
||||||
|
repo_path.mkdir(parents=True)
|
||||||
|
(repo_path / "partial-file").write_text("incomplete")
|
||||||
|
|
||||||
|
def clone(_url, _key, destination, _branch, project_name=None):
|
||||||
|
assert destination == str(clone_dir)
|
||||||
|
assert project_name is None
|
||||||
|
assert not repo_path.exists()
|
||||||
|
(repo_path / ".git").mkdir(parents=True)
|
||||||
|
return str(repo_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(instance_service, "clone_repository", clone)
|
||||||
|
|
||||||
|
result = clone_git_repo(remote_url, "main", str(tmp_path))
|
||||||
|
|
||||||
|
assert result == str(repo_path)
|
||||||
|
assert (repo_path / ".git").is_dir()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestStackProfileMountsWithGitMounts:
|
class TestStackProfileMountsWithGitMounts:
|
||||||
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
||||||
|
|
||||||
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None:
|
def test_exact_overlap_keeps_profile_files_out_of_git_source(
|
||||||
"""When a profile mount targets the same directory as a git mount,
|
self, tmp_path
|
||||||
the profile files should be copied into the git-mount source so the
|
) -> None:
|
||||||
container sees both sets of files through one bind mount."""
|
"""Overlaps must not dirty the shared Git checkout."""
|
||||||
git_source = tmp_path / "git" / "repo-clone"
|
git_source = tmp_path / "git" / "repo-clone"
|
||||||
git_source.mkdir(parents=True)
|
git_source.mkdir(parents=True)
|
||||||
(git_source / "existing.txt").write_text("from git")
|
(git_source / "existing.txt").write_text("from git")
|
||||||
@@ -99,13 +153,12 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
profile_mounts, git_mount_volumes
|
profile_mounts, git_mount_volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == profile_mounts
|
||||||
assert (git_source / "existing.txt").read_text() == "from git"
|
assert (git_source / "existing.txt").read_text() == "from git"
|
||||||
assert (git_source / "settings.json").read_text() == "{}"
|
assert not (git_source / "settings.json").exists()
|
||||||
|
|
||||||
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
|
def test_descendant_overlap_keeps_sources_isolated(self, tmp_path) -> None:
|
||||||
"""Profile mounts targeting a child directory are copied into the
|
"""A child profile mount must not mutate the shared Git checkout."""
|
||||||
corresponding subdirectory of the git-mount source."""
|
|
||||||
git_source = tmp_path / "git"
|
git_source = tmp_path / "git"
|
||||||
git_source.mkdir()
|
git_source.mkdir()
|
||||||
(git_source / "README").write_text("repo")
|
(git_source / "README").write_text("repo")
|
||||||
@@ -129,8 +182,8 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
profile_mounts, git_mount_volumes
|
profile_mounts, git_mount_volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == profile_mounts
|
||||||
assert (git_source / "agent" / "settings.json").read_text() == "x"
|
assert not (git_source / "agent").exists()
|
||||||
assert (git_source / "README").read_text() == "repo"
|
assert (git_source / "README").read_text() == "repo"
|
||||||
|
|
||||||
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
|
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
|
||||||
@@ -190,9 +243,8 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
|
|
||||||
assert result == profile_mounts
|
assert result == profile_mounts
|
||||||
|
|
||||||
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
|
def test_profile_source_file_does_not_mutate_git_source(self, tmp_path) -> None:
|
||||||
"""A profile mount that supplies a single file is copied into the
|
"""A profile file must not be copied into a shared Git checkout."""
|
||||||
git-mount source directory."""
|
|
||||||
git_source = tmp_path / "git"
|
git_source = tmp_path / "git"
|
||||||
git_source.mkdir()
|
git_source.mkdir()
|
||||||
|
|
||||||
@@ -214,8 +266,8 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
profile_mounts, git_mount_volumes
|
profile_mounts, git_mount_volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == profile_mounts
|
||||||
assert (git_source / "settings.json").read_text() == "{}"
|
assert not (git_source / "settings.json").exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Unit tests for TerminalSession docker exec invocation."""
|
"""Unit tests for TerminalSession PTY handling."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from src.services.terminal.terminal_session import TerminalSession
|
from src.services.terminal.terminal_session import TerminalSession
|
||||||
|
|
||||||
@@ -19,15 +19,19 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
|
|||||||
container_user="dev",
|
container_user="dev",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
|
patch(
|
||||||
"src.services.terminal.terminal_session.pty.openpty",
|
"src.services.terminal.terminal_session.pty.openpty",
|
||||||
return_value=(1, 2),
|
return_value=(1, 2),
|
||||||
):
|
),
|
||||||
with patch(
|
patch("src.services.terminal.terminal_session.os.set_blocking") as set_blocking,
|
||||||
|
patch("src.services.terminal.terminal_session.tty.setraw"),
|
||||||
|
patch(
|
||||||
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
||||||
new=AsyncMock(),
|
new=AsyncMock(),
|
||||||
) as mock_exec:
|
) as mock_exec,
|
||||||
with patch("src.services.terminal.terminal_session.os.close"):
|
patch("src.services.terminal.terminal_session.os.close"),
|
||||||
|
):
|
||||||
await session.start()
|
await session.start()
|
||||||
|
|
||||||
args, _kwargs = mock_exec.call_args
|
args, _kwargs = mock_exec.call_args
|
||||||
@@ -37,6 +41,7 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
|
|||||||
user_index = args.index("--user")
|
user_index = args.index("--user")
|
||||||
assert args[user_index + 1] == "dev"
|
assert args[user_index + 1] == "dev"
|
||||||
assert "container-123" in args
|
assert "container-123" in args
|
||||||
|
set_blocking.assert_called_once_with(1, False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -49,16 +54,44 @@ async def test_start_omits_user_when_not_configured() -> None:
|
|||||||
container_id="container-123",
|
container_id="container-123",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
|
patch(
|
||||||
"src.services.terminal.terminal_session.pty.openpty",
|
"src.services.terminal.terminal_session.pty.openpty",
|
||||||
return_value=(1, 2),
|
return_value=(1, 2),
|
||||||
):
|
),
|
||||||
with patch(
|
patch("src.services.terminal.terminal_session.os.set_blocking"),
|
||||||
|
patch("src.services.terminal.terminal_session.tty.setraw"),
|
||||||
|
patch(
|
||||||
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
||||||
new=AsyncMock(),
|
new=AsyncMock(),
|
||||||
) as mock_exec:
|
) as mock_exec,
|
||||||
with patch("src.services.terminal.terminal_session.os.close"):
|
patch("src.services.terminal.terminal_session.os.close"),
|
||||||
|
):
|
||||||
await session.start()
|
await session.start()
|
||||||
|
|
||||||
args, _kwargs = mock_exec.call_args
|
args, _kwargs = mock_exec.call_args
|
||||||
assert "--user" not in args
|
assert "--user" not in args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_input_retries_partial_pty_writes() -> None:
|
||||||
|
"""A large paste is fully written even when the PTY accepts it in chunks."""
|
||||||
|
session = TerminalSession(
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
|
instance_id=uuid.uuid4(),
|
||||||
|
container_id="container-123",
|
||||||
|
)
|
||||||
|
session._master_fd = 42
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.terminal.terminal_session.os.write",
|
||||||
|
side_effect=[2, 2, 1],
|
||||||
|
) as write:
|
||||||
|
await session.write_input(b"hello")
|
||||||
|
|
||||||
|
assert [bytes(call.args[1]) for call in write.call_args_list] == [
|
||||||
|
b"hello",
|
||||||
|
b"llo",
|
||||||
|
b"o",
|
||||||
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/web
|
dir: apps/web
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend web application serving as the main user interface for the "Headquarter" project, built with React and Vite.
|
Frontend web application for a terminal/code-editor interface named "Headquarter," built with React/TypeScript using Vite and served as a static single-page app.
|
||||||
## parent
|
## parent
|
||||||
index: apps/.pi-map.index.md
|
index: apps/.pi-map.index.md
|
||||||
map: apps/.pi-map.md
|
map: apps/.pi-map.md
|
||||||
|
|||||||
+10
-10
@@ -4,21 +4,21 @@ dir: apps/web
|
|||||||
index: apps/web/.pi-map.index.md
|
index: apps/web/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend web application serving as the main user interface for the "Headquarter" project, built with React and Vite.
|
Frontend web application for a terminal/code-editor interface named "Headquarter," built with React/TypeScript using Vite and served as a static single-page app.
|
||||||
## files
|
## files
|
||||||
- .env.example | Template file showing required environment variables for a Vite-based frontend application | dep: Vite
|
- .env.example | Template/example file defining environment variables for a Vite-based application's API and OAuth configuration | dep: Vite
|
||||||
- .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ES2022 module support | dep: eslint, @typescript-eslint/parser, @typescript-eslint/eslint-plugin
|
- .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ES2022 module support | dep: eslint, @typescript-eslint/parser, @typescript-eslint/eslint-plugin
|
||||||
- Dockerfile | Multi-stage Docker build for a Vite/Node.js frontend application, compiling static assets and serving them via nginx as a non-root user | dep: node:20-alpine, nginx:alpine, npm, wget
|
- Dockerfile | Multi-stage Docker build for a Node.js/Vite frontend application compiled to static assets and served by nginx as a non-root user | dep: node:20-alpine, nginx:alpine, npm, wget
|
||||||
- index.html | Serves as the entry point HTML file for a React application named "Headquarter" that loads a TypeScript module and uses Google Fonts. | dep: Google Fonts (Inter, IBM Plex Mono), React (implied by root div and main.tsx)
|
- index.html | Entry point HTML file for a React application named "Headquarter" that loads a TypeScript module and Google Fonts | dep: Google Fonts (Inter, IBM Plex Mono), main.tsx module
|
||||||
- nginx.conf | Configures Nginx as a web server for a single-page application with gzip compression, client-side routing support, cache control for static assets, and a health check endpoint. | dep: nginx
|
- nginx.conf | Configures Nginx as a web server for a single-page application with gzip compression, client-side routing support, cache control for static assets, and a health check endpoint. | dep: nginx
|
||||||
- package-lock.json | Auto-generated dependency lock file that records exact versions and resolved URLs of all npm packages for reproducible installs in the "headquarter-web" React web application. | dep: npm, Node.js, React, Vite, TailwindCSS, xterm, PrismJS, axios, react-router-dom, TypeScript, ESLint, Vitest, @testing-library/react, @phosphor-icons/react, react-simple-code-editor, jsdom, postcss, autoprefixer
|
- package-lock.json | Records exact dependency versions for a React-based web application to ensure reproducible installs | dep: npm, React, React Router, Tailwind CSS, Vite, Vitest, TypeScript, ESLint, xterm, PrismJS, axios, phosphor-icons
|
||||||
- package.json | Defines a React-based web application project named "headquarter-web" with configuration for building, development, testing, and linting using Vite and TypeScript. | dep: react, react-dom, react-router-dom, axios, tailwindcss, prismjs, xterm, @phosphor-icons/react, react-simple-code-editor, vite, typescript, vitest, eslint, @testing-library/react
|
- package.json | Defines a React-based web application project named "headquarter-web" with build tooling, testing, and UI dependencies for a terminal/code editor interface. | dep: react, react-dom, react-router-dom, vite, typescript, tailwindcss, axios, prismjs, xterm, @phosphor-icons/react, react-simple-code-editor, vitest, eslint
|
||||||
- tsconfig.json | Configures TypeScript compiler settings for a modern React project using Vite with ES modules | dep: TypeScript, Vite, React, DOM
|
- tsconfig.json | Configures TypeScript compiler options for a modern React/Vite project with ES2020 target and strict type checking | dep: TypeScript, React, Vite
|
||||||
- vite.config.ts | Configures Vite build tool for a React project with custom dev server port and testing setup | dep: vite, @vitejs/plugin-react
|
- vite.config.ts | Configures Vite build tool with React support, dev server port, and Vitest testing settings | dep: vite, @vitejs/plugin-react
|
||||||
## arch
|
## arch
|
||||||
Modern SPA architecture using React with TypeScript, Vite for build tooling, Docker multi-stage builds with nginx static serving, and environment-based configuration.
|
Containerized single-page React/TypeScript app using Vite for build/dev, Vitest for testing, ESLint for linting, and nginx for static asset delivery with client-side routing support.
|
||||||
## tags
|
## tags
|
||||||
react, vite, eslint, typescript, application, nginx, web, configures
|
react, vite, eslint, application, typescript, nginx, configures, web
|
||||||
## symbols
|
## symbols
|
||||||
-
|
-
|
||||||
## workflows
|
## workflows
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#275d4b" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>Headquarter</title>
|
<title>Headquarter</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<title>Headquarter</title>
|
||||||
|
<rect width="64" height="64" rx="15" fill="#275d4b"/>
|
||||||
|
<path fill="#fffef9" d="M17 15h8v13h14V15h8v34h-8V36H25v13h-8z"/>
|
||||||
|
<path fill="#9dcdb7" d="M25 28h14v8H25z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/web/src
|
dir: apps/web/src
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend web application entry point and routing layer for a React-based collaborative development platform.
|
Frontend web client that bootstraps and renders a React single-page application with routing, authentication, and session-driven UI.
|
||||||
## parent
|
## parent
|
||||||
index: apps/web/.pi-map.index.md
|
index: apps/web/.pi-map.index.md
|
||||||
map: apps/web/.pi-map.md
|
map: apps/web/.pi-map.md
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ dir: apps/web/src
|
|||||||
index: apps/web/src/.pi-map.index.md
|
index: apps/web/src/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend web application entry point and routing layer for a React-based collaborative development platform.
|
Frontend web client that bootstraps and renders a React single-page application with routing, authentication, and session-driven UI.
|
||||||
## files
|
## files
|
||||||
- main.tsx | Entry point that bootstraps a React application with routing, authentication, and session management providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom
|
- main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session state management | 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 client-side routing configuration for a React application using nested routes with authentication protection and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage
|
- router.tsx | Defines the React Router configuration for a multi-page application with nested routes, protected authentication, 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 declarations for session users, projects, workspaces, and repositories in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
|
- types.ts | Defines TypeScript type declarations for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
|
||||||
## arch
|
## arch
|
||||||
Modular React SPA with provider-based dependency injection, nested route guards for authentication/authorization, and centralized TypeScript type definitions.
|
React SPA built with a declarative React Router configuration, protected/nested routes, and centralized TypeScript types for users, sessions, projects, repositories, and workspaces.
|
||||||
## tags
|
## tags
|
||||||
pages, styles, css, react, router, session, dom, project
|
pages, styles, css, router, react, session, dom, project
|
||||||
## symbols
|
## symbols
|
||||||
- AppRouter
|
- AppRouter
|
||||||
- SessionUser
|
- SessionUser
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigProfileRefreshOutcome {
|
||||||
|
instance_id: string;
|
||||||
|
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigProfile {
|
export interface ConfigProfile {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -16,6 +22,7 @@ export interface ConfigProfile {
|
|||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
refresh_outcomes?: ConfigProfileRefreshOutcome[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigProfileMount {
|
export interface ConfigProfileMount {
|
||||||
@@ -138,6 +145,15 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
|
|||||||
await apiClient.delete(`/config-profiles/${id}`);
|
await apiClient.delete(`/config-profiles/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const refreshConfigProfileGitMounts = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
refresh_outcomes: ConfigProfileRefreshOutcome[];
|
||||||
|
}>(`/config-profiles/${id}/refresh-git-mounts`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateProfileIncludes = async (
|
export const updateProfileIncludes = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateIncludesRequest,
|
data: UpdateIncludesRequest,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/web/src/components
|
dir: apps/web/src/components
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Reusable React UI components providing the application's shared visual elements, layout shell, authentication guards, and notification infrastructure.
|
Package components
|
||||||
## parent
|
## parent
|
||||||
index: apps/web/src/.pi-map.index.md
|
index: apps/web/src/.pi-map.index.md
|
||||||
map: apps/web/src/.pi-map.md
|
map: apps/web/src/.pi-map.md
|
||||||
|
|||||||
@@ -4,22 +4,22 @@ dir: apps/web/src/components
|
|||||||
index: apps/web/src/components/.pi-map.index.md
|
index: apps/web/src/components/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Reusable React UI components providing the application's shared visual elements, layout shell, authentication guards, and notification infrastructure.
|
Package components
|
||||||
## files
|
## files
|
||||||
- app-shell.tsx | Provides the main application shell layout with navigation, session management, and responsive mobile/desktop rendering for a React application. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../utils/open-session, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
|
- app-shell.tsx | Main application layout shell with responsive navigation, session management, and global state providers for notifications and toasts. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../utils/open-session, ../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-highlighting code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
|
- code-editor.tsx | A React component that renders a syntax-highlighting code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
|
||||||
- data-states.tsx | Provides reusable React components for displaying loading, error, and empty data states in a UI. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, icon
|
- data-states.tsx | Provides reusable UI components for rendering loading, error, and empty data states. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, Icon
|
||||||
- icon.tsx | Provides a centralized, type-safe React icon component that maps semantic names to Phosphor icons with configurable size, weight, color, and accessibility properties. | exp: IconName, IconProps, Icon | dep: react, @phosphor-icons/react
|
- icon.tsx | Provides a centralized, type-safe React icon component that maps semantic names to Phosphor icons with configurable sizes, weights, and colors. | exp: IconName, IconProps, Icon | dep: react, @phosphor-icons/react
|
||||||
- loading-overlay.tsx | Renders an accessible loading overlay with optional label when visible prop is true | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon
|
- loading-overlay.tsx | Renders an accessible loading overlay with optional text label when visible | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon
|
||||||
- protected-route.test.tsx | Tests a ProtectedRoute component that guards private content based on authentication state | dep: @testing-library/react, react-router-dom, vitest, ./protected-route, ../state/auth
|
- protected-route.test.tsx | Tests a ProtectedRoute component that guards content based on authentication state, showing loading or redirecting to login | dep: @testing-library/react, react-router-dom, vitest, ./protected-route
|
||||||
- protected-route.tsx | A React component that conditionally renders children or redirects to login based on authentication state, preserving the intended destination URL. | exp: ProtectedRoute | dep: react-router-dom, ../state/auth
|
- protected-route.tsx | Conditionally renders children or redirects to login based on authentication state | exp: ProtectedRoute | dep: react-router-dom, ../state/auth
|
||||||
- syntax-highlighter.tsx | Renders syntax-highlighted code blocks with optional line numbers, language badge, and copy-to-clipboard functionality. | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language, React, icon, language utilities
|
- syntax-highlighter.tsx | A React component that renders syntax-highlighted code with optional line numbers and a copy-to-clipboard button | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language, React, icon, language utils
|
||||||
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
|
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
|
||||||
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic | 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, InstanceEventPayload
|
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic | 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, InstanceEventPayload
|
||||||
## arch
|
## arch
|
||||||
Component-based architecture with functional React patterns, separation of presentational components (data states, icons, syntax highlighting) from behavioral logic (toast rules, protected routes), and centralized design abstractions (icon mapping, code editor wrapping).
|
Contains 10 files.
|
||||||
## tags
|
## tags
|
||||||
toast, react, state, icon, code, loading, event, editor
|
toast, state, icon, react, loading, code, event, editor
|
||||||
## symbols
|
## symbols
|
||||||
- LoadingOverlay
|
- LoadingOverlay
|
||||||
- mapEventToCategory
|
- mapEventToCategory
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/web/src/components/features
|
dir: apps/web/src/components/features
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Reusable UI feature components that compose domain-specific functionality for the web application.
|
Feature-specific UI components organized by business domain/capability for the web
|
||||||
## parent
|
## parent
|
||||||
index: apps/web/src/components/.pi-map.index.md
|
index: apps/web/src/components/.pi-map.index.md
|
||||||
map: apps/web/src/components/.pi-map.md
|
map: apps/web/src/components/.pi-map.md
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
|
|||||||
index: apps/web/src/components/features/.pi-map.index.md
|
index: apps/web/src/components/features/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Reusable UI feature components that compose domain-specific functionality for the web application.
|
Feature-specific UI components organized by business domain/capability for the web
|
||||||
## files
|
## files
|
||||||
## arch
|
## arch
|
||||||
Feature-based component organization with domain-driven design patterns, likely combining presentational components with localized state and business logic.
|
Contains 0 files.
|
||||||
## tags
|
## tags
|
||||||
-
|
-
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface Props {
|
|||||||
onSubmit: (e?: React.FormEvent) => void;
|
onSubmit: (e?: React.FormEvent) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
|
onRefreshGitMounts: () => void;
|
||||||
onAddInclude: (id: string) => void;
|
onAddInclude: (id: string) => void;
|
||||||
onRemoveInclude: (index: number) => void;
|
onRemoveInclude: (index: number) => void;
|
||||||
onDragStart: (e: React.DragEvent, index: number) => void;
|
onDragStart: (e: React.DragEvent, index: number) => void;
|
||||||
@@ -63,6 +64,7 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onRefreshGitMounts,
|
||||||
onAddInclude,
|
onAddInclude,
|
||||||
onRemoveInclude,
|
onRemoveInclude,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
@@ -114,6 +116,9 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
{!isCreating && selectedProfile && (
|
{!isCreating && selectedProfile && (
|
||||||
<div className="row row-sm">
|
<div className="row row-sm">
|
||||||
|
<button className="btn btn-secondary" onClick={onRefreshGitMounts}>
|
||||||
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||||
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||||
{previewingId === selectedProfile.id ? (
|
{previewingId === selectedProfile.id ? (
|
||||||
<><Icon name="loading" size="sm" /> Previewing...</>
|
<><Icon name="loading" size="sm" /> Previewing...</>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ interface Props {
|
|||||||
) => void;
|
) => void;
|
||||||
onRemoveMountFile: (mountIndex: number, path: string) => void;
|
onRemoveMountFile: (mountIndex: number, path: string) => void;
|
||||||
onPreview: (id: string) => void;
|
onPreview: (id: string) => void;
|
||||||
|
onRefreshGitMounts: (id: string) => void;
|
||||||
onClosePreview: () => void;
|
onClosePreview: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ export const ConfigProfilesMobileView = ({
|
|||||||
onUpdateMountFile,
|
onUpdateMountFile,
|
||||||
onRemoveMountFile,
|
onRemoveMountFile,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onRefreshGitMounts,
|
||||||
onClosePreview,
|
onClosePreview,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -363,6 +365,13 @@ export const ConfigProfilesMobileView = ({
|
|||||||
onDelete={handleDeleteClick}
|
onDelete={handleDeleteClick}
|
||||||
>
|
>
|
||||||
<div className="mobile-detail-actions-extra">
|
<div className="mobile-detail-actions-extra">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/web/src/components/features/terminal
|
dir: apps/web/src/components/features/terminal
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides responsive, cross-platform terminal UI components with session management, virtual special keys, and WebSocket-backed xterm.js integration for web-based terminal access.
|
Provides the UI components for a responsive, multi-session terminal interface in the web app, including desktop and mobile layouts, session tabs, and special-key input controls.
|
||||||
## parent
|
## parent
|
||||||
index: apps/web/src/components/features/.pi-map.index.md
|
index: apps/web/src/components/features/.pi-map.index.md
|
||||||
map: apps/web/src/components/features/.pi-map.md
|
map: apps/web/src/components/features/.pi-map.md
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ dir: apps/web/src/components/features/terminal
|
|||||||
index: apps/web/src/components/features/terminal/.pi-map.index.md
|
index: apps/web/src/components/features/terminal/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides responsive, cross-platform terminal UI components with session management, virtual special keys, and WebSocket-backed xterm.js integration for web-based terminal access.
|
Provides the UI components for a responsive, multi-session terminal interface in the web app, including desktop and mobile layouts, session tabs, and special-key input controls.
|
||||||
## files
|
## files
|
||||||
- DesktopTerminalView.tsx | Renders a desktop terminal view with session tabs, fullscreen controls, and multiple terminal instances | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal
|
- DesktopTerminalView.tsx | Renders the desktop layout for a multi-session terminal UI, managing tabs, fullscreen mode, font controls, and reset confirmation. | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, TerminalSession
|
||||||
- MobileTerminalView.tsx | Renders a mobile-optimized terminal interface with session tabs, toolbar controls, special keys input, and keyboard-aware layout adjustments. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal, terminal, terminal-session-tabs, icon, special-keys-strip, special-keys-panel, use-special-keys, api/terminal
|
- MobileTerminalView.tsx | Renders a mobile-specific terminal interface with session tabs, toolbar controls, special key inputs, and multiple terminal instances. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, Icon, SpecialKeysStrip, SpecialKeysPanel
|
||||||
- special-keys-panel.tsx | Renders an overlay panel of special keyboard keys (Home, End, F1-F12, Ctrl+C, etc.) that sends terminal sequences when clicked and supports modifier key combinations. | exp: SpecialKeysPanel | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook
|
- special-keys-panel.tsx | Renders a modal overlay panel of special keyboard keys (Home, End, F1-F12, Ctrl+C, etc.) that sends escape sequences to a terminal when clicked. | exp: SpecialKeysPanel | dep: react, ../../../hooks/use-special-keys
|
||||||
- special-keys-strip.tsx | Renders a strip of virtual special keys (Esc, Tab, Ctrl, Alt, arrows, etc.) for sending terminal key sequences with optional modifier support. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, use-special-keys hook
|
- special-keys-strip.tsx | Renders a touch-accessible strip of special keyboard keys (Esc, Tab, arrows, modifiers) that sends terminal escape sequences with optional modifier support. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook
|
||||||
- terminal-session-tabs.test.tsx | Tests a React terminal session tabs component for rendering, selection, closing with confirmation, renaming, session limits, and connection status display. | dep: @testing-library/react, vitest, ./terminal-session-tabs
|
- terminal-session-tabs.test.tsx | Unit tests for a TerminalSessionTabs React component covering tab rendering, selection, close confirmation, inline renaming, session limit enforcement, and connection status display. | dep: @testing-library/react, vitest, ./terminal-session-tabs
|
||||||
- terminal-session-tabs.tsx | Renders an interactive tab bar for managing multiple terminal sessions with selection, renaming, closing, and creation capabilities | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React, useState, useRef, useCallback
|
- terminal-session-tabs.tsx | Renders a tabbed interface for managing multiple terminal sessions with support for switching, renaming, closing with confirmation, and creating new sessions. | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React
|
||||||
- terminal.tsx | A React terminal component that provides an interactive xterm.js-based terminal with WebSocket connectivity, mobile touch support, and session management. | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys, React
|
- terminal.tsx | | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys
|
||||||
## arch
|
## arch
|
||||||
Component-based architecture with platform-specific view layers (desktop/mobile), modular special keys subsystems (strip/overlay panel), tab-based session management with React state, and xterm.js integration via WebSocket abstraction with touch/mobile adaptations.
|
Modular React component architecture organized by feature, with separate presentational components for desktop/mobile viewports, shared tab/session management, and helper components for terminal input handling.
|
||||||
## tags
|
## tags
|
||||||
terminal, special, keys, session, tabs, react, view, strip
|
terminal, special, session, keys, react, tabs, view, strip
|
||||||
## symbols
|
## symbols
|
||||||
- DesktopTerminalView
|
- DesktopTerminalView
|
||||||
- MobileTerminalView
|
- MobileTerminalView
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTerminalScrollbackLimit,
|
||||||
|
isCurrentWebSocket,
|
||||||
|
shouldRetryWebSocketClose,
|
||||||
|
} from "./terminal.tsx";
|
||||||
|
|
||||||
|
describe("getTerminalScrollbackLimit", () => {
|
||||||
|
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
|
||||||
|
expect(getTerminalScrollbackLimit(true)).toBe(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => {
|
||||||
|
expect(getTerminalScrollbackLimit(false)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale WebSocket callbacks after a replacement connection", () => {
|
||||||
|
const current = {} as WebSocket;
|
||||||
|
const stale = {} as WebSocket;
|
||||||
|
|
||||||
|
expect(isCurrentWebSocket(current, current)).toBe(true);
|
||||||
|
expect(isCurrentWebSocket(current, stale)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a heartbeat timeout but not a server socket replacement", () => {
|
||||||
|
expect(shouldRetryWebSocketClose(4000, "Heartbeat timeout")).toBe(true);
|
||||||
|
expect(shouldRetryWebSocketClose(4000, "New connection established")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,33 @@ const MIN_FONT_SIZE = 4;
|
|||||||
const MAX_FONT_SIZE = 24;
|
const MAX_FONT_SIZE = 24;
|
||||||
const RECONNECT_ATTEMPTS = 3;
|
const RECONNECT_ATTEMPTS = 3;
|
||||||
const RECONNECT_DELAY_BASE = 1000;
|
const RECONNECT_DELAY_BASE = 1000;
|
||||||
|
const BRACKETED_PASTE_ENABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x68];
|
||||||
|
const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x6c];
|
||||||
|
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
||||||
|
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
||||||
|
|
||||||
|
export function getTerminalScrollbackLimit(isMobile: boolean): number {
|
||||||
|
return isMobile ? 10_000 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCurrentWebSocket(
|
||||||
|
current: WebSocket | null,
|
||||||
|
candidate: WebSocket,
|
||||||
|
): boolean {
|
||||||
|
return current === candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRetryWebSocketClose(code: number, reason: string): boolean {
|
||||||
|
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesByteSequence(
|
||||||
|
data: Uint8Array,
|
||||||
|
start: number,
|
||||||
|
sequence: readonly number[],
|
||||||
|
): boolean {
|
||||||
|
return sequence.every((byte, index) => data[start + index] === byte);
|
||||||
|
}
|
||||||
|
|
||||||
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||||
(
|
(
|
||||||
@@ -68,7 +95,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const bracketedPasteEnabledRef = useRef(false);
|
||||||
|
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<number | null>(null);
|
||||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||||
onTerminalReadyRef.current = onTerminalReady;
|
onTerminalReadyRef.current = onTerminalReady;
|
||||||
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
||||||
@@ -97,7 +127,23 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
return fontSize;
|
return fontSize;
|
||||||
}, [fontSize]);
|
}, [fontSize]);
|
||||||
|
|
||||||
|
const clearReconnectTimer = useCallback(() => {
|
||||||
|
if (reconnectTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
|
const currentWs = wsRef.current;
|
||||||
|
if (
|
||||||
|
currentWs?.readyState === WebSocket.CONNECTING ||
|
||||||
|
currentWs?.readyState === WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return currentWs;
|
||||||
|
}
|
||||||
|
clearReconnectTimer();
|
||||||
|
|
||||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
@@ -111,7 +157,47 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
let bracketedPasteControlTail = new Uint8Array(0);
|
||||||
|
const updateBracketedPasteMode = (data: Uint8Array) => {
|
||||||
|
const combined = new Uint8Array(
|
||||||
|
bracketedPasteControlTail.length + data.length,
|
||||||
|
);
|
||||||
|
combined.set(bracketedPasteControlTail);
|
||||||
|
combined.set(data, bracketedPasteControlTail.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < combined.length; index++) {
|
||||||
|
if (
|
||||||
|
index + BRACKETED_PASTE_ENABLE_SEQUENCE.length <= combined.length &&
|
||||||
|
matchesByteSequence(
|
||||||
|
combined,
|
||||||
|
index,
|
||||||
|
BRACKETED_PASTE_ENABLE_SEQUENCE,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
bracketedPasteEnabledRef.current = true;
|
||||||
|
} else if (
|
||||||
|
index + BRACKETED_PASTE_DISABLE_SEQUENCE.length <= combined.length &&
|
||||||
|
matchesByteSequence(
|
||||||
|
combined,
|
||||||
|
index,
|
||||||
|
BRACKETED_PASTE_DISABLE_SEQUENCE,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
bracketedPasteEnabledRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bracketedPasteControlTail = combined.slice(
|
||||||
|
Math.max(0, combined.length - BRACKETED_PASTE_CONTROL_TAIL_LENGTH),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
if (!isCurrentWebSocket(wsRef.current, ws)) {
|
||||||
|
ws.close(1000, "Superseded connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
setError(null);
|
setError(null);
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -152,10 +238,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (!termRef.current) return;
|
if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return;
|
||||||
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const data = new Uint8Array(event.data);
|
const data = new Uint8Array(event.data);
|
||||||
|
updateBracketedPasteMode(data);
|
||||||
termRef.current.write(data);
|
termRef.current.write(data);
|
||||||
|
|
||||||
// Flow control: accumulate processed bytes
|
// Flow control: accumulate processed bytes
|
||||||
@@ -210,6 +297,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
|
if (!isCurrentWebSocket(wsRef.current, ws)) return;
|
||||||
|
wsRef.current = null;
|
||||||
|
if (ackTimeout) window.clearTimeout(ackTimeout);
|
||||||
|
|
||||||
// Clean up heartbeat check
|
// Clean up heartbeat check
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
@@ -225,18 +316,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code === 1000) {
|
if (!shouldRetryWebSocketClose(event.code, event.reason)) {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code === 4000) {
|
// Transient errors: attempt reconnection.
|
||||||
// Server closed old connection for concurrent connection - don't reconnect
|
|
||||||
// The new connection is already established
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transient errors: attempt reconnection
|
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
setError(`Connection closed (code: ${event.code})`);
|
||||||
|
|
||||||
@@ -245,11 +330,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
const delay =
|
const delay =
|
||||||
RECONNECT_DELAY_BASE *
|
RECONNECT_DELAY_BASE *
|
||||||
Math.pow(2, reconnectAttemptsRef.current - 1);
|
Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
setTimeout(() => {
|
clearReconnectTimer();
|
||||||
if (isUnmountingRef.current) {
|
reconnectTimerRef.current = window.setTimeout(() => {
|
||||||
return;
|
reconnectTimerRef.current = null;
|
||||||
}
|
if (
|
||||||
if (document.visibilityState !== "hidden") {
|
!isUnmountingRef.current &&
|
||||||
|
document.visibilityState !== "hidden" &&
|
||||||
|
wsRef.current === null
|
||||||
|
) {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -257,15 +345,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
|
if (!isCurrentWebSocket(wsRef.current, ws)) return;
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError("WebSocket error");
|
setError("WebSocket error");
|
||||||
};
|
};
|
||||||
|
|
||||||
return ws;
|
return ws;
|
||||||
}, [instanceId, sessionId]);
|
}, [clearReconnectTimer, instanceId, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalRef.current) return;
|
if (!terminalRef.current) return;
|
||||||
|
isUnmountingRef.current = false;
|
||||||
|
permanentErrorRef.current = null;
|
||||||
|
|
||||||
// Initialize terminal
|
// Initialize terminal
|
||||||
const currentFontSize = calculateFontSize();
|
const currentFontSize = calculateFontSize();
|
||||||
@@ -276,10 +367,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
scrollback: 10000,
|
// Desktop tools repaint in place, so retaining their normal buffer
|
||||||
|
// creates stale frames that native wheel scrolling can revisit. Mobile
|
||||||
|
// instead uses its custom touch handler to scroll normal-buffer output,
|
||||||
|
// which requires retained history.
|
||||||
|
scrollback: getTerminalScrollbackLimit(isMobile),
|
||||||
ignoreBracketedPasteMode: false,
|
ignoreBracketedPasteMode: false,
|
||||||
fastScrollSensitivity: 5,
|
fastScrollSensitivity: 0,
|
||||||
scrollSensitivity: 1,
|
scrollSensitivity: 0,
|
||||||
smoothScrollDuration: 0,
|
smoothScrollDuration: 0,
|
||||||
theme: {
|
theme: {
|
||||||
background: "#1e1e1e",
|
background: "#1e1e1e",
|
||||||
@@ -352,7 +447,33 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
// Open xterm first (must happen before fit)
|
// Open xterm first (must happen before fit)
|
||||||
term.open(container);
|
term.open(container);
|
||||||
term.focus();
|
term.focus();
|
||||||
const ws = connectWebSocket();
|
connectWebSocket();
|
||||||
|
|
||||||
|
pasteTextRef.current = (text: string) => {
|
||||||
|
const currentWs = wsRef.current;
|
||||||
|
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
if (bracketedPasteEnabledRef.current) {
|
||||||
|
const normalizedText = text.replace(/\r\n|\r|\n/g, "\r");
|
||||||
|
currentWs.send(`\x1b[200~${normalizedText}\x1b[201~`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
term.paste(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowserPaste = (event: ClipboardEvent) => {
|
||||||
|
if (!bracketedPasteEnabledRef.current) return;
|
||||||
|
const text = event.clipboardData?.getData("text/plain");
|
||||||
|
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
pasteTextRef.current(text);
|
||||||
|
};
|
||||||
|
container.addEventListener("paste", handleBrowserPaste, true);
|
||||||
|
|
||||||
// Mobile touch scroll.
|
// Mobile touch scroll.
|
||||||
// In normal mode xterm.js has a scrollable viewport; in alternate
|
// In normal mode xterm.js has a scrollable viewport; in alternate
|
||||||
@@ -574,14 +695,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
// Visibility API for reconnection
|
// Visibility API for reconnection
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
|
const currentWs = wsRef.current;
|
||||||
if (
|
if (
|
||||||
document.visibilityState === "visible" &&
|
document.visibilityState === "visible" &&
|
||||||
ws &&
|
!permanentErrorRef.current &&
|
||||||
ws.readyState !== WebSocket.OPEN
|
(currentWs === null || currentWs.readyState === WebSocket.CLOSED)
|
||||||
) {
|
) {
|
||||||
if (permanentErrorRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
@@ -590,6 +709,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isUnmountingRef.current = true;
|
isUnmountingRef.current = true;
|
||||||
|
clearReconnectTimer();
|
||||||
clearTimeout(resizeTimeout);
|
clearTimeout(resizeTimeout);
|
||||||
clearTimeout(windowResizeTimeout);
|
clearTimeout(windowResizeTimeout);
|
||||||
clearTimeout(headerHideTimeout);
|
clearTimeout(headerHideTimeout);
|
||||||
@@ -600,8 +720,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
handleVisibilityChange,
|
handleVisibilityChange,
|
||||||
);
|
);
|
||||||
if (touchCleanup) touchCleanup();
|
if (touchCleanup) touchCleanup();
|
||||||
if (ws) {
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||||
ws.close(1000, "Component unmounting");
|
pasteTextRef.current = () => {};
|
||||||
|
bracketedPasteEnabledRef.current = false;
|
||||||
|
const currentWs = wsRef.current;
|
||||||
|
wsRef.current = null;
|
||||||
|
if (currentWs) {
|
||||||
|
currentWs.close(1000, "Component unmounting");
|
||||||
}
|
}
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
@@ -613,7 +738,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
// Ignore disposal errors from partially torn-down terminal
|
// Ignore disposal errors from partially torn-down terminal
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [instanceId, connectWebSocket]);
|
}, [instanceId, connectWebSocket, isMobile]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fit: () => {
|
fit: () => {
|
||||||
@@ -721,10 +846,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
const handlePaste = async () => {
|
const handlePaste = async () => {
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
pasteTextRef.current(await navigator.clipboard.readText());
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(text);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard API not available
|
// Clipboard API not available
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: apps/web/src/components/features/tool
|
dir: apps/web/src/components/features/tool
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides UI components for managing containerized development tools, including instance lifecycle management, manifest configuration, and workspace-integrated tool launching.
|
Provides UI components for managing containerized development tools, including starting, configuring, monitoring, and editing tool instances across workspaces.
|
||||||
## parent
|
## parent
|
||||||
index: apps/web/src/components/features/.pi-map.index.md
|
index: apps/web/src/components/features/.pi-map.index.md
|
||||||
map: apps/web/src/components/features/.pi-map.md
|
map: apps/web/src/components/features/.pi-map.md
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ dir: apps/web/src/components/features/tool
|
|||||||
index: apps/web/src/components/features/tool/.pi-map.index.md
|
index: apps/web/src/components/features/tool/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Provides UI components for managing containerized development tools, including instance lifecycle management, manifest configuration, and workspace-integrated tool launching.
|
Provides UI components for managing containerized development tools, including starting, configuring, monitoring, and editing tool instances across workspaces.
|
||||||
## files
|
## files
|
||||||
- instance-list.tsx | React component that displays and manages a list of tool instances with CRUD operations, real-time status updates, and configuration profile selection. | exp: InstanceList | dep: react, react-router-dom, ../../icon, ../../loading-overlay, ../../../api/sessions, ../../../api/tool-types, ../session/create-session-form, ../../../api/config-profiles, ../../../api/ssh-keys, ../../../state/events, ../../../state/sessions
|
- instance-list.tsx | Displays and manages tool instances for a project/repo with start/stop/restart/delete actions, config profile selection, SSH key selection, and real-time status updates. | exp: InstanceList | dep: react, react-router-dom, ../../icon, ../../loading-overlay, ../../../api/sessions, ../../../api/tool-types, ../session/create-session-form, ../../../api/config-profiles, ../../../api/ssh-keys, ../../../state/events, ../../../state/sessions, Icon, LoadingOverlay, sessions API, config-profiles API, ssh-keys API, events state, sessions state
|
||||||
- manifest-editor.tsx | A React component that provides a form-based UI for editing container manifest configurations including base images, packages, environment variables, scripts, mounts, and runtime settings, with live preview generation capability. | exp: ManifestEditor | dep: react, ../../icon, ../../../utils/errors, ../../../api/tool-definitions, icon, errors utils, tool-definitions API
|
- manifest-editor.tsx | A React component that provides a form-based UI for editing container manifest configurations including base images, packages, environment variables, scripts, mounts, and runtime settings, with live preview compilation. | exp: ManifestEditor | dep: react, ../../icon, ../../../utils/errors, ../../../api/tool-definitions, icon, errors utils, tool-definitions API
|
||||||
- start-tool-fab.tsx | Renders a floating action button that opens a modal to select a workspace and start a tool. | exp: func:StartToolFAB(), call:useState, call:setOpen, call:setWorkspacesLoading, call:listAllWorkspaces, call:setWorkspaces, call:setSelectedWorkspace, call:e.stopPropagation, call:workspaces.find, call:workspaces.map | dep: react, ../../icon, ./tool-starter, ../../../types/workspace, ../../../api/workspaces, icon, tool-starter, workspace types, workspaces api
|
- start-tool-fab.tsx | Renders a floating action button that opens a modal for selecting a workspace and starting a tool. | exp: func:StartToolFAB(), call:useState, call:setOpen, call:setWorkspacesLoading, call:listAllWorkspaces, call:setWorkspaces, call:setSelectedWorkspace, call:e.stopPropagation, call:workspaces.find, call:workspaces.map | dep: react, ../../icon, ./tool-starter, ../../../types/workspace, ../../../api/workspaces
|
||||||
- start-tool-modal.tsx | React modal component for selecting a tool type and optional config profile to start a tool on a workspace | exp: StartToolModalProps, func:StartToolModal({ workspace, onClose, onStart, }: StartToolModalProps), call:useState, call:useAsyncData, call:e.preventDefault, call:setError, call:setSubmitting, call:onStart, call:onClose, call:e.stopPropagation, call:setToolTypeId, call:toolTypes?.map, call:setConfigProfileId | dep: react, ../../icon, ../../../api/tool-types, ../../../hooks/use-async-data, ../../../types/workspace
|
- start-tool-modal.tsx | A React modal component that allows users to select a tool type and optional configuration profile to start a tool on a specific workspace. | exp: StartToolModalProps, func:StartToolModal({ workspace, onClose, onStart, }: StartToolModalProps), call:useState, call:useAsyncData, call:e.preventDefault, call:setError, call:setSubmitting, call:onStart, call:onClose, call:e.stopPropagation, call:setToolTypeId, call:toolTypes?.map, call:setConfigProfileId | dep: react, ../../icon, ../../../api/tool-types, ../../../hooks/use-async-data, ../../../types/workspace, icon, tool-types, use-async-data, workspace
|
||||||
- tool-starter.tsx | React component for starting a tool instance within a workspace with configurable tool types, profiles, and SSH keys | exp: ToolStarterProps, func:ToolStarter({ workspace, onStarted, onCancel, }: ToolStarterProps), call:useSessions, call:useState, call:useEffect, call:listToolTypes, call:setToolTypes, call:setToolTypesError, call:setToolTypesLoading, call:load, call:setProfiles, call:setSelectedProfileId, call:setProfilesLoading, call:listConfigProfiles, call:data.find, call:listSSHKeys, call:setSshKeys, call:setSelectedSshKeyIds, call:console.error, call:setSshKeysLoading, call:sshKeys.find, call:useCallback, call:setError, call:setStarting, call:createInstance, call:displayName.trim, call:startInstance, call:addOrUpdateSession, call:onStarted, call:setSelectedToolTypeId, call:toolTypes.find, call:setDisplayName, call:toolTypes.map, call:setNameEdited, call:profiles.map, call:sshKeys.map, call:selectedSshKeyIds.includes, call:prev.filter | dep: react, ../../icon, ../../loading-overlay, ../../../api/tool-types, ../../../api/config-profiles, ../../../api/ssh-keys, ../../../state/sessions, ../../../types/workspace, ../../../api/sessions, icon, loading-overlay, tool-types, config-profiles, ssh-keys, sessions, workspace types, sessions api
|
- tool-starter.tsx | React component for starting a tool instance within a workspace context, fetching and configuring tool types, profiles, and SSH keys. | exp: ToolStarterProps, func:ToolStarter({ workspace, onStarted, onCancel, }: ToolStarterProps), call:useSessions, call:useState, call:useEffect, call:listToolTypes, call:setToolTypes, call:setToolTypesError, call:setToolTypesLoading, call:load, call:setProfiles, call:setSelectedProfileId, call:setProfilesLoading, call:listConfigProfiles, call:data.find, call:listSSHKeys, call:setSshKeys, call:setSelectedSshKeyIds, call:console.error, call:setSshKeysLoading, call:sshKeys.find, call:useCallback, call:setError, call:setStarting, call:createInstance, call:displayName.trim, call:startInstance, call:addOrUpdateSession, call:onStarted, call:setSelectedToolTypeId, call:toolTypes.find, call:setDisplayName, call:toolTypes.map, call:setNameEdited, call:profiles.map, call:sshKeys.map, call:selectedSshKeyIds.includes, call:prev.filter | dep: react, ../../icon, ../../loading-overlay, ../../../api/tool-types, ../../../api/config-profiles, ../../../api/ssh-keys, ../../../state/sessions, ../../../types/workspace, ../../../api/sessions, icon, loading-overlay, tool-types, config-profiles, ssh-keys, sessions, workspace
|
||||||
- tools-bottom-sheet.tsx | Renders a mobile bottom sheet navigation menu for tools with active route highlighting | exp: ToolsBottomSheet | dep: react-router-dom, ../../icon, icon
|
- tools-bottom-sheet.tsx | Renders a mobile bottom sheet navigation menu for tools with active route highlighting | exp: ToolsBottomSheet | dep: react-router-dom, ../../icon, icon
|
||||||
## arch
|
## arch
|
||||||
Feature-based React component architecture with modal/bottom-sheet mobile-responsive patterns, form-driven configuration editing with live preview, and workspace-contextual tool orchestration.
|
React component-based architecture with feature-specific composition, using modal/bottom-sheet patterns for mobile-responsive navigation, form-based configuration editors with live preview, and real-time status polling for instance management.
|
||||||
## tags
|
## tags
|
||||||
call:set, tool, types, start, call:use, ssh, react, loading
|
call:set, tool, types, start, ssh, call:use, icon, loading
|
||||||
## symbols
|
## symbols
|
||||||
- StartToolFAB
|
- StartToolFAB
|
||||||
- StartToolModal
|
- StartToolModal
|
||||||
|
|||||||
@@ -578,7 +578,10 @@ export const InstanceList = ({
|
|||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
|
<div className="dialog-header">
|
||||||
<h2>Launch Tool</h2>
|
<h2>Launch Tool</h2>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-body">
|
||||||
<CreateSessionForm
|
<CreateSessionForm
|
||||||
projects={[]}
|
projects={[]}
|
||||||
repositories={[]}
|
repositories={[]}
|
||||||
@@ -593,6 +596,7 @@ export const InstanceList = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const ManifestEditor = ({
|
|||||||
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
||||||
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
||||||
const [command, setCommand] = useState<string[]>([""]);
|
const [command, setCommand] = useState<string[]>([""]);
|
||||||
const [workingDir, setWorkingDir] = useState("/workspace");
|
const [workingDir, setWorkingDir] = useState("");
|
||||||
const [stdinOpen, setStdinOpen] = useState(true);
|
const [stdinOpen, setStdinOpen] = useState(true);
|
||||||
const [tty, setTty] = useState(true);
|
const [tty, setTty] = useState(true);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export const ManifestEditor = ({
|
|||||||
|
|
||||||
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
||||||
setCommand((runtime.command as string[]) || [""]);
|
setCommand((runtime.command as string[]) || [""]);
|
||||||
setWorkingDir((runtime.working_dir as string) || "/workspace");
|
setWorkingDir((runtime.working_dir as string) || "");
|
||||||
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
||||||
setTty((runtime.tty as boolean) ?? true);
|
setTty((runtime.tty as boolean) ?? true);
|
||||||
}, [manifest]);
|
}, [manifest]);
|
||||||
@@ -561,7 +561,8 @@ export const ManifestEditor = ({
|
|||||||
<textarea
|
<textarea
|
||||||
value={script}
|
value={script}
|
||||||
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
||||||
placeholder="chown -R user:user /workspace"
|
placeholder="chown -R user:user $HOME/$WORKSPACE_NAME"
|
||||||
|
|
||||||
className="form-input font-mono text-sm flex-1"
|
className="form-input font-mono text-sm flex-1"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
@@ -723,7 +724,7 @@ export const ManifestEditor = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={workingDir}
|
value={workingDir}
|
||||||
onChange={(e) => setWorkingDir(e.target.value)}
|
onChange={(e) => setWorkingDir(e.target.value)}
|
||||||
placeholder="/workspace"
|
placeholder="e.g., /workspace or ~/code"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function StartToolFAB() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
{workspacesLoading ? (
|
{workspacesLoading ? (
|
||||||
<p className="muted">Loading workspaces...</p>
|
<p className="muted">Loading workspaces...</p>
|
||||||
) : workspaces.length === 0 ? (
|
) : workspaces.length === 0 ? (
|
||||||
@@ -112,6 +113,7 @@ export function StartToolFAB() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { WorkspaceToolsPanel } from "./workspace-tools-panel";
|
||||||
|
import type { Workspace } from "../../../types/workspace";
|
||||||
|
|
||||||
|
vi.mock("../../../hooks/use-workspace-instances", () => ({
|
||||||
|
useWorkspaceInstances: () => ({
|
||||||
|
instances: [],
|
||||||
|
loading: false,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../tool/tool-starter", () => ({
|
||||||
|
ToolStarter: () => <div data-testid="tool-starter" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workspace: Workspace = {
|
||||||
|
id: "workspace-1",
|
||||||
|
name: "Main",
|
||||||
|
repo_id: "repo-1",
|
||||||
|
repo_name: "repository",
|
||||||
|
repo_ssh_key_id: null,
|
||||||
|
project_id: "project-1",
|
||||||
|
project_name: "Project",
|
||||||
|
user_id: "user-1",
|
||||||
|
branch: "main",
|
||||||
|
path: "/workspace",
|
||||||
|
status: "ready",
|
||||||
|
last_sync_at: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
instance_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("WorkspaceToolsPanel", () => {
|
||||||
|
it("places the tool launcher inside the shared scrollable dialog body", () => {
|
||||||
|
const { container } = render(<WorkspaceToolsPanel workspace={workspace} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start Tool" }));
|
||||||
|
|
||||||
|
const dialogBody = container.querySelector(".dialog-body");
|
||||||
|
expect(dialogBody).toContainElement(screen.getByTestId("tool-starter"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,7 +64,10 @@ export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
|
|||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="dialog-overlay" onClick={() => setShowModal(false)}>
|
<div className="dialog-overlay" onClick={() => setShowModal(false)}>
|
||||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dialog-header">
|
||||||
<h3>Start Tool</h3>
|
<h3>Start Tool</h3>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-body">
|
||||||
<ToolStarter
|
<ToolStarter
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
onStarted={() => {
|
onStarted={() => {
|
||||||
@@ -75,6 +78,7 @@ export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
deleteConfigProfile,
|
deleteConfigProfile,
|
||||||
listConfigProfiles,
|
listConfigProfiles,
|
||||||
previewConfigProfile,
|
previewConfigProfile,
|
||||||
|
refreshConfigProfileGitMounts,
|
||||||
updateConfigProfile,
|
updateConfigProfile,
|
||||||
updateProfileIncludes,
|
updateProfileIncludes,
|
||||||
type ConfigProfile,
|
type ConfigProfile,
|
||||||
@@ -34,17 +35,21 @@ export const useConfigProfiles = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
const [formData, setFormData] =
|
||||||
|
useState<CreateConfigProfileRequest>(defaultForm);
|
||||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
const selectedProfile =
|
||||||
|
profiles.find((p) => p.id === selectedProfileId) || null;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -89,7 +94,9 @@ export const useConfigProfiles = () => {
|
|||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
setIncludedProfileIds(
|
setIncludedProfileIds(
|
||||||
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
profile.includes.map(
|
||||||
|
(inc: { included_profile_id: string }) => inc.included_profile_id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
@@ -208,20 +215,39 @@ export const useConfigProfiles = () => {
|
|||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const newProfile = await createConfigProfile(formData);
|
const newProfile = await createConfigProfile(formData);
|
||||||
if (includedProfileIds.length > 0) {
|
if (includedProfileIds.length > 0) {
|
||||||
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
await updateProfileIncludes(newProfile.id, {
|
||||||
|
includes: includedProfileIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setSelectedProfileId(newProfile.id);
|
setSelectedProfileId(newProfile.id);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
const refreshed = (await listConfigProfiles()).find(
|
||||||
|
(p) => p.id === newProfile.id,
|
||||||
|
);
|
||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
} else if (selectedProfile) {
|
} else if (selectedProfile) {
|
||||||
await updateConfigProfile(selectedProfile.id, formData);
|
const updatedProfile = await updateConfigProfile(
|
||||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
selectedProfile.id,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
await updateProfileIncludes(selectedProfile.id, {
|
||||||
|
includes: includedProfileIds,
|
||||||
|
});
|
||||||
|
const restartOutcomes = updatedProfile.refresh_outcomes?.filter(
|
||||||
|
(outcome) => outcome.status === "restart_required",
|
||||||
|
);
|
||||||
|
if (restartOutcomes?.length) {
|
||||||
|
setError(
|
||||||
|
`Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
const refreshed = (await listConfigProfiles()).find(
|
||||||
|
(p) => p.id === selectedProfile.id,
|
||||||
|
);
|
||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -233,7 +259,8 @@ export const useConfigProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
if (!window.confirm("Are you sure you want to delete this config profile?"))
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await deleteConfigProfile(id);
|
await deleteConfigProfile(id);
|
||||||
if (selectedProfileId === id) {
|
if (selectedProfileId === id) {
|
||||||
@@ -242,8 +269,27 @@ export const useConfigProfiles = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch (err) {
|
||||||
alert("Failed to delete config profile");
|
setError(extractErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await refreshConfigProfileGitMounts(id);
|
||||||
|
const refreshed = result.refresh_outcomes.filter(
|
||||||
|
(outcome) => outcome.status === "refreshed",
|
||||||
|
);
|
||||||
|
setError(
|
||||||
|
refreshed.length
|
||||||
|
? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.`
|
||||||
|
: "No running instances currently use this profile's Git mounts.",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractErrorMessage(err));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,7 +314,10 @@ export const useConfigProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEnvVar = () => {
|
const addEnvVar = () => {
|
||||||
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
env_vars: { ...prev.env_vars, "": "" },
|
||||||
|
}));
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,7 +372,10 @@ export const useConfigProfiles = () => {
|
|||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
const updateMount = (
|
||||||
|
index: number,
|
||||||
|
updates: Partial<ConfigProfile["mounts"][0]>,
|
||||||
|
) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const mounts = [...(prev.mounts || [])];
|
const mounts = [...(prev.mounts || [])];
|
||||||
mounts[index] = { ...mounts[index], ...updates };
|
mounts[index] = { ...mounts[index], ...updates };
|
||||||
@@ -402,6 +454,7 @@ export const useConfigProfiles = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
updateEnvVar,
|
updateEnvVar,
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ export const useTerminalPage = () => {
|
|||||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||||
if (!isAltShift) return;
|
if (!isAltShift) return;
|
||||||
|
|
||||||
|
const activeSessionIndex = activeSessionId
|
||||||
|
? sessions.findIndex((session) => session.id === activeSessionId)
|
||||||
|
: -1;
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case "n":
|
case "n":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -187,17 +191,14 @@ export const useTerminalPage = () => {
|
|||||||
break;
|
break;
|
||||||
case "arrowleft":
|
case "arrowleft":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeSessionId) {
|
if (activeSessionIndex > 0) {
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
setActiveSessionId(sessions[activeSessionIndex - 1].id);
|
||||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "arrowright":
|
case "arrowright":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeSessionId) {
|
if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
setActiveSessionId(sessions[activeSessionIndex + 1].id);
|
||||||
if (idx < sessions.length - 1)
|
|
||||||
setActiveSessionId(sessions[idx + 1].id);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "r":
|
case "r":
|
||||||
@@ -208,6 +209,8 @@ export const useTerminalPage = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsFullscreen((prev) => !prev);
|
setIsFullscreen((prev) => !prev);
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
updateEnvVar,
|
updateEnvVar,
|
||||||
@@ -135,6 +136,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
onUpdateMountFile={updateMountFile}
|
onUpdateMountFile={updateMountFile}
|
||||||
onRemoveMountFile={removeMountFile}
|
onRemoveMountFile={removeMountFile}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
|
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
|
||||||
onClosePreview={() => setPreviewData(null)}
|
onClosePreview={() => setPreviewData(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -176,6 +178,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
|
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
|
||||||
|
onRefreshGitMounts={() => selectedProfile && handleRefreshGitMounts(selectedProfile.id)}
|
||||||
onAddInclude={addInclude}
|
onAddInclude={addInclude}
|
||||||
onRemoveInclude={removeInclude}
|
onRemoveInclude={removeInclude}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./ProjectsPage";
|
import { ProjectsPage } from "./ProjectsPage";
|
||||||
import * as projectsApi from "../api/projects";
|
import * as projectsApi from "../api/projects";
|
||||||
|
import { SessionsProvider } from "../state/sessions";
|
||||||
|
import type { ProjectWithRepos } from "../types";
|
||||||
|
|
||||||
const mockProjects = [
|
vi.mock("../api/sessions", () => ({
|
||||||
|
getUserSessions: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProjects: ProjectWithRepos[] = [
|
||||||
{
|
{
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "Alpha Project",
|
name: "Alpha Project",
|
||||||
description: "First project",
|
description: "First project",
|
||||||
owner_id: "user-1",
|
owner_id: "user-1",
|
||||||
default_ssh_key_id: null,
|
default_ssh_key_id: null,
|
||||||
|
repositories: [],
|
||||||
|
created_at: "2026-07-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "proj-2",
|
id: "proj-2",
|
||||||
@@ -19,6 +28,8 @@ const mockProjects = [
|
|||||||
description: null,
|
description: null,
|
||||||
owner_id: "user-1",
|
owner_id: "user-1",
|
||||||
default_ssh_key_id: null,
|
default_ssh_key_id: null,
|
||||||
|
repositories: [],
|
||||||
|
created_at: "2026-07-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -31,9 +42,7 @@ describe("ProjectsPage", () => {
|
|||||||
it("renders loading state initially", () => {
|
it("renders loading state initially", () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -41,9 +50,7 @@ describe("ProjectsPage", () => {
|
|||||||
it("renders project list after loading", async () => {
|
it("renders project list after loading", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -56,9 +63,7 @@ describe("ProjectsPage", () => {
|
|||||||
it("renders empty state when no projects", async () => {
|
it("renders empty state when no projects", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -69,9 +74,7 @@ describe("ProjectsPage", () => {
|
|||||||
it("renders error state with retry button", async () => {
|
it("renders error state with retry button", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -85,9 +88,7 @@ describe("ProjectsPage", () => {
|
|||||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -118,9 +119,7 @@ describe("ProjectsPage", () => {
|
|||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -138,16 +137,14 @@ describe("ProjectsPage", () => {
|
|||||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||||
if (!alphaCard) throw new Error("Card not found");
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
||||||
@@ -171,16 +168,14 @@ describe("ProjectsPage", () => {
|
|||||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||||
if (!alphaCard) throw new Error("Card not found");
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||||
|
|||||||
@@ -453,12 +453,16 @@
|
|||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog,
|
||||||
|
.modal-content,
|
||||||
|
.commit-dialog {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
max-height: calc(100vh - var(--space-8));
|
max-height: calc(100vh - var(--space-8));
|
||||||
|
max-height: calc(100dvh - var(--space-8));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -468,16 +472,6 @@
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
/* deprecated alias */
|
/* deprecated alias */
|
||||||
width: 100%;
|
|
||||||
max-width: 32rem;
|
|
||||||
max-height: calc(100vh - var(--space-8));
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-lg {
|
.dialog-lg {
|
||||||
@@ -500,9 +494,13 @@
|
|||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-body {
|
.dialog-body,
|
||||||
|
.modal-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,9 +541,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog,
|
.dialog,
|
||||||
.modal-content {
|
.modal-content,
|
||||||
|
.commit-dialog {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: calc(100vh - var(--space-6));
|
max-height: calc(100vh - var(--space-6));
|
||||||
|
max-height: calc(100dvh - var(--space-6));
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family:
|
||||||
|
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-medium {
|
.font-medium {
|
||||||
@@ -938,6 +940,14 @@ a.nav-item,
|
|||||||
overscroll-behavior: auto;
|
overscroll-behavior: auto;
|
||||||
max-height: 100% !important;
|
max-height: 100% !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
/* Full-screen TUI tools repaint in place; scrollback is 0 (see terminal.tsx).
|
||||||
|
Hide the viewport scrollbar so it never appears or captures input. */
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On mobile the custom touch handler scrolls the buffer; disable native
|
/* On mobile the custom touch handler scrolls the buffer; disable native
|
||||||
@@ -2318,8 +2328,10 @@ a.nav-item,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
|
max-height: 70dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
animation: slide-up 0.2s ease-out;
|
animation: slide-up 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2355,8 +2367,12 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-bottom-sheet-content {
|
.mobile-bottom-sheet-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-bottom-sheet-item {
|
.mobile-bottom-sheet-item {
|
||||||
@@ -3230,7 +3246,10 @@ a:active,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
|
max-height: 80dvh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
animation: slideUp 0.3s ease;
|
animation: slideUp 0.3s ease;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
}
|
}
|
||||||
@@ -3592,6 +3611,7 @@ a:active,
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
|
max-height: 70dvh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3809,7 +3829,9 @@ a:active,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition:
|
||||||
|
background 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
@@ -3836,7 +3858,9 @@ a:active,
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.12s ease, border-color 0.12s ease;
|
transition:
|
||||||
|
background 0.12s ease,
|
||||||
|
border-color 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-header-action-btn:hover {
|
.workspace-header-action-btn:hover {
|
||||||
@@ -3911,7 +3935,9 @@ a:active,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition:
|
||||||
|
background 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-button:hover {
|
.copy-button:hover {
|
||||||
@@ -4157,7 +4183,10 @@ a:active,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drag-item-active {
|
.drag-item-active {
|
||||||
background: var(--brand-light, color-mix(in srgb, var(--brand) 10%, transparent));
|
background: var(
|
||||||
|
--brand-light,
|
||||||
|
color-mix(in srgb, var(--brand) 10%, transparent)
|
||||||
|
);
|
||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4173,7 +4202,9 @@ a:active,
|
|||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family:
|
||||||
|
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
monospace;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
}
|
}
|
||||||
@@ -4321,7 +4352,9 @@ a:active,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: border-color 0.15s, color 0.15s;
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-create-button:hover {
|
.sidebar-create-button:hover {
|
||||||
@@ -4346,7 +4379,9 @@ a:active,
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-fab:hover {
|
.mobile-fab:hover {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ map: openspec/.pi-map.md
|
|||||||
- openspec/changes/archive
|
- openspec/changes/archive
|
||||||
index: openspec/changes/archive/.pi-map.index.md
|
index: openspec/changes/archive/.pi-map.index.md
|
||||||
map: openspec/changes/archive/.pi-map.md
|
map: openspec/changes/archive/.pi-map.md
|
||||||
|
- openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||||
|
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
|
||||||
- openspec/changes/fix-container-status-false-positive
|
- openspec/changes/fix-container-status-false-positive
|
||||||
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
|
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
|
||||||
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
|
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add a Headquarter Favicon
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a compact, recognizable favicon for Headquarter and register it in the web document head.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Use a geometric cream `H` on the product's evergreen brand field. The mark remains identifiable at small browser-tab sizes, avoids font rendering dependencies, and matches both light and dark application themes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/web/public/favicon.svg`
|
||||||
|
- `apps/web/index.html`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The browser uses a dedicated Headquarter favicon.
|
||||||
|
- [ ] The mark remains legible at small sizes and on light or dark browser chrome.
|
||||||
|
- [ ] The HTML declares the icon type and a matching browser theme color.
|
||||||
|
- [ ] Frontend production build passes.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Add a Headquarter Favicon — Tasks
|
||||||
|
|
||||||
|
- [x] Create the favicon asset.
|
||||||
|
- [x] Register the favicon and browser theme color in the web entry document.
|
||||||
|
- [x] Run the frontend production build.
|
||||||
|
- [x] Update project maps for changed source files.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# openspec/changes/fix-config-profile-git-mount-clone-reuse (index)
|
||||||
|
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
|
||||||
|
## role
|
||||||
|
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
|
||||||
|
## parent
|
||||||
|
index: openspec/changes/.pi-map.index.md
|
||||||
|
map: openspec/changes/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- change.md
|
||||||
|
- tasks.md
|
||||||
|
## links
|
||||||
|
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||||
|
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||||
|
|
||||||
|
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
|
||||||
|
## files
|
||||||
|
- change.md | Describes the cached clone regression, required behavior, implementation scope, and verification plan.
|
||||||
|
- tasks.md | Tracks investigation, regression testing, implementation, project-map maintenance, verification, and commit status.
|
||||||
|
## arch
|
||||||
|
Documentation-only OpenSpec change package with separate change rationale and implementation task checklist.
|
||||||
|
## tags
|
||||||
|
config, profile, git, mount, clone, cache, startup, fix
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Fix config profile git mount clone reuse
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Starting a tool instance with a Config Profile git mount can omit the mount when the repository was already cloned into the instance cache. The startup log reports that the destination path already exists and is not an empty directory.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`clone_git_repo()` computes a deterministic cache directory but calls `clone_repository()` before checking whether that directory already contains a clone. `git clone` therefore fails on repeated starts or overlapping start requests. `resolve_single_git_mount()` treats auxiliary mount failures as non-blocking, so startup continues without the configured volume.
|
||||||
|
|
||||||
|
## Required behavior
|
||||||
|
|
||||||
|
1. A valid existing git mount clone must be reused and updated instead of cloned again.
|
||||||
|
2. A missing clone must still be created normally.
|
||||||
|
3. An incomplete clone directory must not permanently prevent a later retry.
|
||||||
|
4. A clone/update failure remains non-blocking at the git mount resolver boundary.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Correct clone-cache handling in `apps/api/src/services/tool/instance_service.py`.
|
||||||
|
- Add focused regression tests in `apps/api/tests/unit/test_instance_service.py`.
|
||||||
|
- No API, database, frontend, or Docker Compose contract changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Targeted `pytest` for git mount clone reuse and instance service tests.
|
||||||
|
- Ruff and mypy checks for changed backend files.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Tasks: fix config profile git mount clone reuse
|
||||||
|
|
||||||
|
- [x] Capture the failing runtime trace from an affected tool session.
|
||||||
|
- [x] Add a regression test proving a valid cached clone is reused.
|
||||||
|
- [x] Update `clone_git_repo()` to check the deterministic clone path before cloning.
|
||||||
|
- [x] Recover safely from an incomplete clone directory.
|
||||||
|
- [x] Run targeted backend tests and quality checks.
|
||||||
|
- [x] Update project maps and validate map freshness.
|
||||||
|
- [x] Commit the verified fix.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Constrain and Scroll Edit Dialogs and Popups
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Ensure every edit dialog, form popup, and modal popup remains usable on a viewport that is shorter than its content. Dialog chrome must stay within the visible viewport while the content area scrolls independently.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The shared dialog system constrains only components that follow its `dialog-header` / `dialog-body` structure. Several tool-launch and commit dialogs place form content directly inside the container or use a bespoke container, so long forms can be clipped. Existing viewport sizing also relies on `vh`, which is unreliable when mobile browser chrome changes height.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Shared dialog and modal CSS in `apps/web/src/styles/global.css`.
|
||||||
|
- Mobile notification dropdown sizing in `apps/web/src/styles/utilities.css`.
|
||||||
|
- Tool-launch and commit popup markup that does not currently provide a scrollable content region.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Dialogs and modal popups are bounded by the current visible viewport, including mobile dynamic viewport changes.
|
||||||
|
- [ ] Headers and footer/action bars remain visible while long form content scrolls independently.
|
||||||
|
- [ ] Tool-launch and commit popups use the shared scrollable content pattern.
|
||||||
|
- [ ] Mobile sheets, action sheets, and notification dropdowns remain scrollable without propagating scroll gestures to the page.
|
||||||
|
- [ ] Relevant frontend tests, typecheck, lint, and production build pass.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Redesigning dialog visuals or interaction flows.
|
||||||
|
- Changing page-level scrolling outside overlays.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Constrain and Scroll Edit Dialogs and Popups — Tasks
|
||||||
|
|
||||||
|
- [x] Audit all dialog, modal, sheet, action-sheet, and popup implementations.
|
||||||
|
- [x] Strengthen shared dialog/modal viewport and body scrolling rules.
|
||||||
|
- [x] Update bespoke tool-launch and commit popups to use scrollable content regions.
|
||||||
|
- [x] Add focused coverage for the shared scrollable dialog-body markup.
|
||||||
|
- [x] Run frontend typecheck, lint, tests (88 passed), and production build.
|
||||||
|
- [x] Update project maps for changed source files.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Restore Mobile Terminal Scrolling
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The recent terminal scrollback optimization disabled scrollback for every viewport. Mobile terminal swipe handling still scrolls xterm's normal buffer programmatically, so swipes in normal-buffer tools no longer have retained output to move through.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`468f342` changed the terminal configuration to `scrollback: 0` globally to prevent stale repaint frames and wheel scrolling on desktop. The mobile touch handler calls `term.scrollLines()` when the normal buffer is active. With zero scrollback, that call has no scrollable history and becomes a no-op.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/terminal/terminal.tsx`
|
||||||
|
- Focused terminal configuration test
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Retain a bounded xterm scrollback buffer on mobile only (`10000` lines), while leaving desktop at zero scrollback and with wheel sensitivity disabled. Mobile's existing custom touch handler remains responsible for moving through normal-buffer history; alternate-screen swipes continue to send SGR wheel events to the active TUI.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] A mobile terminal with normal-buffer output exceeding one screen scrolls via a vertical swipe.
|
||||||
|
- [ ] Alternate-screen terminal scrolling continues to use the existing SGR wheel-event path.
|
||||||
|
- [ ] Desktop keeps zero xterm scrollback and disabled native wheel scrolling, so stale repaint frames do not return.
|
||||||
|
- [ ] Focused unit test and frontend quality gates pass.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `468f342 fix(terminal): hide scrollbar and stop stale-frame wheel scroll for TUI tools`
|
||||||
|
- `openspec/changes/fix-terminal-container-overflow`
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Restore Mobile Terminal Scrolling — Tasks
|
||||||
|
|
||||||
|
- [x] Add a mobile-specific terminal scrollback limit while preserving zero scrollback on desktop.
|
||||||
|
- [x] Reinitialize the terminal when the responsive mobile classification changes so its scrollback and touch handler match the active viewport.
|
||||||
|
- [x] Add focused tests for the responsive scrollback configuration.
|
||||||
|
- [x] Run the full frontend test suite (89 tests passed after repairing the `ProjectsPage` test setup).
|
||||||
|
- [x] Run frontend typecheck, lint, focused tests, and production build.
|
||||||
|
- [ ] Perform mobile normal-buffer and alternate-screen manual QA.
|
||||||
|
- [ ] Update project maps for changed source files (the map patch tool currently fails with an unsupported `temperature` parameter).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Fix Web Terminal Resilience
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Prevent large browser pastes from blocking flow-control acknowledgements, and prevent stale reconnect callbacks from replacing a healthy terminal WebSocket.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The terminal WebSocket handler awaits each PTY write inline. A large paste can wait for the PTY to become writable while the same handler stops receiving acknowledgement messages. If output flow control has paused PTY reads, the acknowledgement that would resume output remains unread, leaving the terminal apparently frozen.
|
||||||
|
|
||||||
|
Separately, reconnect timers and visibility callbacks can create a second socket after a connection becomes healthy. The terminal manager then closes the existing session socket, interrupting active input or rendering.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Queue bounded terminal input onto a single ordered writer so the WebSocket receive loop continues handling acknowledgements, resize, reset, and disconnect messages.
|
||||||
|
- Make browser reconnection single-owner: stale socket callbacks and retry timers MUST NOT replace a current healthy socket.
|
||||||
|
- Add focused regression tests for the queueing and reconnect behavior.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Re-enable desktop normal-buffer scrollback or mouse-wheel scrolling. Desktop continues to use zero scrollback and disabled wheel sensitivity to avoid the known stale TUI-frame regression.
|
||||||
|
- Change terminal session persistence, authentication, PTY transport, or mobile touch scrolling behavior.
|
||||||
|
|
||||||
|
## Risk and rollback
|
||||||
|
|
||||||
|
The bounded input queue must preserve input ordering, reject excess input without blocking control messages, and be cancelled when the WebSocket disconnects. Socket ownership checks must not prevent a legitimate reconnect after a real disconnect. Roll back by reverting the backend queue and frontend ownership changes; existing direct PTY input and retry behavior then resumes.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] A blocked PTY write does not prevent the WebSocket handler from processing a subsequent flow-control acknowledgement.
|
||||||
|
- [ ] Input bytes are still written to the PTY in arrival order, and excess queued input is rejected rather than growing without limit.
|
||||||
|
- [ ] A stale socket close event or retry callback cannot replace an open current socket.
|
||||||
|
- [ ] Component cleanup cancels pending reconnect timers and closes the current socket.
|
||||||
|
- [ ] Desktop scrollback and wheel settings remain unchanged.
|
||||||
|
- [ ] Focused backend/frontend tests and relevant quality gates pass.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Fix Web Terminal Resilience — Tasks
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ------- | ------- |
|
||||||
|
| Estimated changed lines | 180–280 |
|
||||||
|
| 400-line budget risk | Low |
|
||||||
|
| Chained PRs recommended | No |
|
||||||
|
| Suggested split | Single focused change |
|
||||||
|
| Delivery strategy | single-pr |
|
||||||
|
| Chain strategy | feature-branch-chain |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: No
|
||||||
|
Chain strategy: feature-branch-chain
|
||||||
|
400-line budget risk: Low
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] **RED — backend input/control concurrency:** characterize a PTY write that waits for readiness while an acknowledgement is received; prove the acknowledgement is handled without waiting for that write to finish.
|
||||||
|
- [x] **GREEN — ordered input writer:** move PTY writes behind one cancellable ordered queue/worker while retaining the current public WebSocket message protocol and input ordering.
|
||||||
|
- [x] **TRIANGULATE — lifecycle:** cover worker cancellation and queued-write failure/disconnect handling.
|
||||||
|
- [x] **RED — frontend socket ownership:** characterize stale close/retry callbacks after a newer socket has become current.
|
||||||
|
- [x] **GREEN — reconnect ownership:** ensure only the current socket can update state or schedule a retry; cancel retry timers during cleanup.
|
||||||
|
- [x] **REFACTOR:** keep the connection lifecycle readable and avoid changing the intentional desktop scrollback configuration.
|
||||||
|
- [x] **Verify:** run targeted backend and frontend tests, frontend typecheck/lint/build, backend checks practical in the isolated worktree, and inspect diagnostics. (Backend pytest is unavailable locally: no pytest/uv executable; Docker test execution was explicitly declined.)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Refresh profile-managed configuration for running tool instances when a Config Profile is saved, without recreating the container or terminal session.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Resolve the saved profile and refresh every running instance that selected it, including profiles that include it.
|
||||||
|
- Store non-Git profile configuration as a canonical, host-side working copy shared by every instance using the profile.
|
||||||
|
- Bind-mount canonical profile directories directly into their configured container targets and bind-mount canonical profile files individually under the container working directory, preserving the workspace mount.
|
||||||
|
- Allow UI and container-side edits to the same canonical files; last writer wins, with an overwrite warning when detectable.
|
||||||
|
- Defer Git mount refresh and Git-clone mutation to a separate commit-aware feature.
|
||||||
|
- Standardize all supported tool containers on one shared non-root user/group so canonical writable profile mounts remain accessible across instances.
|
||||||
|
- Return per-instance refresh results to the profile-save UI.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Preserve running containers and terminal sessions.
|
||||||
|
- Preserve the workspace/repository mount.
|
||||||
|
- Docker bind-mount topology is immutable at runtime. Added, removed, retargeted, or mode-changed mounts MUST be reported as requiring restart, not partially applied.
|
||||||
|
- Desktop and mobile profile editors use the same save/refresh behavior.
|
||||||
|
- The standardized container user/group MUST be applied to built-in tool definitions, generated manifests, and image templates; legacy/incompatible tool images must report incompatible permissions rather than silently changing profile mount ownership.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Saving a profile refreshes every eligible running instance with a direct or transitive dependency on that profile.
|
||||||
|
- [ ] Canonical non-Git profile files and mount directories are shared writable working copies across compatible running instances.
|
||||||
|
- [ ] Container-side and UI-side changes become visible to all instances using the profile; last writer wins and detectable overwrites generate a warning.
|
||||||
|
- [ ] Git mount refresh and Git clone mutation are not performed by this feature.
|
||||||
|
- [ ] Built-in supported tool containers use a shared non-root user/group compatible with writable canonical profile mounts.
|
||||||
|
- [ ] Topology changes return a restart-required result without recreating the instance.
|
||||||
|
- [ ] Terminal WebSocket sessions remain connected throughout a successful refresh.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Design: Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Canonical working copies
|
||||||
|
|
||||||
|
Each non-Git Config Profile owns canonical host-side storage. Directory mounts use canonical profile directories; each top-level profile file uses a canonical host file bind-mounted under the container working directory. All compatible instances selected for the profile mount the same sources, so UI and container edits are immediately shared.
|
||||||
|
|
||||||
|
## Container compatibility
|
||||||
|
|
||||||
|
Supported built-in tools standardize on one non-root user/group. Existing instances retain their current image/user and report `restart_required`. Custom tools are not rewritten; tools that do not opt into the shared user/group return `incompatible_permissions`.
|
||||||
|
|
||||||
|
## Save behavior
|
||||||
|
|
||||||
|
A profile save writes canonical profile files atomically per file. The save response reports affected compatible instances, `restart_required` topology/runtime changes, `incompatible_permissions`, and detectable overwrite warnings. Last writer wins; no merge or lock protocol is imposed.
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
Git mount mutation is explicitly deferred. Workspace/repository mounts are never changed. Profile deletion is rejected while running instances still use the profile.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementation Plan: Live Config Profile Refresh
|
||||||
|
|
||||||
|
1. Standardize built-in tool user/group definitions and remove ownership-changing behavior for shared profile sources.
|
||||||
|
2. Add canonical profile storage and bind-mount compilation for profile directories and individual working-directory files.
|
||||||
|
3. Add compatibility/topology analysis, running-instance discovery, save-result schema, and deletion guard.
|
||||||
|
4. Wire desktop/mobile profile save results into immediate status/warning feedback.
|
||||||
|
5. Add unit, API, manifest/image, and frontend coverage; run quality gates and manual two-instance QA.
|
||||||
|
|
||||||
|
## Delivery order
|
||||||
|
|
||||||
|
1. Container-user compatibility
|
||||||
|
2. Canonical non-Git profile mounts
|
||||||
|
3. API and deletion semantics
|
||||||
|
4. UI feedback
|
||||||
|
5. Verification and commit
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Git mount refresh and Git clone mutation require a commit-aware follow-up change.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Live Config Profile Refresh — Tasks
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Estimated changed lines | 500–750 |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | Container-user standardization → canonical profile mounts → API/UI feedback → verification |
|
||||||
|
| Delivery strategy | feature-branch-chain |
|
||||||
|
| Chain strategy | feature-branch-chain |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
Chain strategy: feature-branch-chain
|
||||||
|
400-line budget risk: High
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] **RED/GREEN — shared container user:** standardize built-in tool images, manifests, and permission handling on one shared non-root user/group; detect incompatible legacy images.
|
||||||
|
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts.
|
||||||
|
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings.
|
||||||
|
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation.
|
||||||
|
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors.
|
||||||
|
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests.
|
||||||
|
|
||||||
|
## Verification Notes
|
||||||
|
|
||||||
|
- Passed: frontend production build (`npm run build`), Python compilation for changed backend modules, and targeted LSP diagnostics.
|
||||||
|
- Skipped: backend pytest and Ruff; this environment has no project-managed Python runner, system Python lacks those packages, and the user declined system-package installation.
|
||||||
|
- Skipped: Docker/Compose and manual multi-instance checks; explicit Docker approval was not granted.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Test Plan: Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- Canonical file and directory paths are profile-scoped and reject traversal.
|
||||||
|
- Two compatible instances receive the same host mount source.
|
||||||
|
- A container-side file edit is visible through the profile read API and another instance mount.
|
||||||
|
- A profile save updates canonical content and returns overwrite warnings when applicable.
|
||||||
|
- Topology, environment, runtime, and legacy-instance changes return `restart_required`.
|
||||||
|
- Incompatible users return `incompatible_permissions`.
|
||||||
|
- Deleting a profile with running dependents is rejected with their instance identifiers.
|
||||||
|
- Git mount content is unchanged by this feature.
|
||||||
|
|
||||||
|
## Container/image compatibility
|
||||||
|
|
||||||
|
- Each built-in supported image uses the common non-root UID/GID.
|
||||||
|
- Generated manifest Dockerfiles and entrypoints retain that user and writable mount access.
|
||||||
|
- Existing instances are not mutated until restart/recreation.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- Desktop and mobile save flows display refreshed/shared-working-copy, overwrite-warning, restart-required, and incompatible-permissions results.
|
||||||
|
|
||||||
|
## Manual QA
|
||||||
|
|
||||||
|
- Open two compatible instances using one profile; edit a mounted file in one terminal and verify it in the other.
|
||||||
|
- Save a profile edit and verify both running instances see it without terminal disconnection.
|
||||||
|
- Verify profile deletion is blocked while either instance is running.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Live Git Config Mount Refresh
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Git-backed Config Profile mounts are currently cloned per instance. Their content cannot be refreshed consistently for running sessions, and writable container mounts can dirty the checkout.
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
Move Git Config Profile mounts to profile-scoped canonical host clones. Bind the already-mounted directory sources read-only into compatible instances and refresh a stable branch/ref checkout in place under a per-clone lock.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
|
||||||
|
- In-place refresh for existing directory mounts only.
|
||||||
|
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
|
||||||
|
- Read-only container Git config mounts.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Writable shared Git configuration mounts.
|
||||||
|
- Global cross-user clone sharing.
|
||||||
|
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
|
||||||
|
- Atomic all-files revision switching for processes already reading the mount.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Design: Live Git Config Mount Refresh
|
||||||
|
|
||||||
|
## Canonical source
|
||||||
|
|
||||||
|
Each selected Config Profile owns canonical Git clone directories beneath:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; clones are never shared across users.
|
||||||
|
|
||||||
|
## Runtime behavior
|
||||||
|
|
||||||
|
1. Resolve Git mounts and map them to canonical sources.
|
||||||
|
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
|
||||||
|
3. Clone into a temporary sibling, then rename on initial creation.
|
||||||
|
4. For refresh, fetch and update the existing working tree in place.
|
||||||
|
5. Bind directory mappings read-only. Existing containers see changed directory contents without recreation.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
|
||||||
|
- Refresh failure is reported without mutating a known-good checkout.
|
||||||
|
- No non-Git profile content may be copied into a Git checkout; overlapping targets are rejected or reported.
|
||||||
|
- Containers must not write to shared Git mount sources.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Host Git operations use only an authorized server-side credential source. Credentials are not part of the mounted checkout and are not exposed to containers.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Live Git Config Mount Refresh — Tasks
|
||||||
|
|
||||||
|
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
|
||||||
|
- [x] Make Git Config Profile mounts read-only and prevent profile-content copy into Git sources.
|
||||||
|
- [x] Add lock-protected clone/fetch/ref checkout refresh that preserves a known-good checkout on failure.
|
||||||
|
- [x] Add save/refresh outcomes for live refresh, restart-required topology, and failures.
|
||||||
|
- [x] Add desktop/mobile feedback for refresh outcomes.
|
||||||
|
- [ ] Add focused resolver/service/API/frontend tests.
|
||||||
|
- [x] Run available verification and document skipped checks.
|
||||||
|
|
||||||
|
## Verification Notes
|
||||||
|
|
||||||
|
- Passed: frontend production build and Python compilation for changed backend modules.
|
||||||
|
- Skipped: backend pytest and Ruff are unavailable in this environment; Docker/manual live-session checks were not approved.
|
||||||
|
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
|
||||||
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
sudo \
|
sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create a non-root user and allow passwordless sudo so the startup
|
# Use the shared built-in UID/GID so writable Config Profile mounts can be
|
||||||
# permission fixer can adjust ownership of bind-mounted directories.
|
# shared by compatible instances without ownership changes.
|
||||||
RUN useradd -m -s /bin/bash user \
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user \
|
||||||
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
|
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
|
||||||
&& chmod 0440 /etc/sudoers.d/user
|
&& chmod 0440 /etc/sudoers.d/user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Set up git
|
# Set up git
|
||||||
RUN git config --global init.defaultBranch main
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
# Code-server runs as abc user by default
|
# The LinuxServer entrypoint maps abc to this shared UID/GID before starting
|
||||||
|
# code-server, so writable Config Profile mounts are compatible with other
|
||||||
|
# built-in tool containers.
|
||||||
|
ENV PUID=1000 \
|
||||||
|
PGID=1000
|
||||||
USER abc
|
USER abc
|
||||||
|
|
||||||
EXPOSE 8443
|
EXPOSE 8443
|
||||||
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
# Set up git
|
# Set up git
|
||||||
RUN git config --global init.defaultBranch main
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
# Switch back to jovyan user (default for scipy-notebook)
|
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
|
||||||
USER ${NB_UID}
|
# Keep the image root at entrypoint time so that mapping can occur.
|
||||||
|
ENV NB_UID=1000 \
|
||||||
|
NB_GID=1000
|
||||||
|
USER root
|
||||||
|
|
||||||
EXPOSE 8888
|
EXPOSE 8888
|
||||||
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Install OpenCode
|
# Install OpenCode
|
||||||
RUN npm install -g opencode
|
RUN npm install -g opencode
|
||||||
|
|
||||||
# Create non-root user
|
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||||
RUN useradd -m -s /bin/bash user
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|
||||||
# Set up git
|
# Set up git
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Install Pi Coding Agent globally
|
# Install Pi Coding Agent globally
|
||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
# Create non-root user
|
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||||
RUN useradd -m -s /bin/bash user
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|
||||||
# Set up git
|
# Set up git
|
||||||
@@ -37,15 +38,11 @@ RUN git config --global init.defaultBranch main \
|
|||||||
&& git config --global user.email "dev@headquarter.local" \
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
&& git config --global user.name "Developer"
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
# Create default tmux config
|
# Create user-owned default configuration files.
|
||||||
RUN printf '%s\n' 'set -g mouse on' 'set -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 \
|
||||||
|
&& mkdir -p /home/user/.config/ranger /home/user/.pi/agent \
|
||||||
# Create default ranger config
|
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
|
||||||
RUN mkdir -p /home/user/.config/ranger \
|
&& chown -R user:user /home/user
|
||||||
&& 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
|
|
||||||
|
|
||||||
USER user
|
USER user
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user