Compare commits

...

48 Commits

Author SHA1 Message Date
Developer 38d116dcf7 docs(openspec): mark pass 1 tasks complete 2026-06-16 12:24:31 +00:00
Developer da3f8da3ac docs(openspec): add web ui spacing/typography proposal, spec, tasks and audit 2026-06-16 12:24:10 +00:00
Developer 454a7845bb feat(web/ui): replace inline font-size violations and use new primitives in components 2026-06-16 12:24:04 +00:00
Developer 50e3af0d91 feat(web/ui): add mobile detail/search, ssh/profile utilities and nav-height tokens 2026-06-16 12:23:59 +00:00
Developer 8e1af41cd3 feat(web/ui): expand design tokens and add primitive component styles 2026-06-16 12:23:53 +00:00
Developer 355b471067 feat(web): redesign navbar live session items for scanability
Restructure live session nav entries into a 3-line layout:
- workspace/repository context on top
- session display name in the middle
- tool type and project on the bottom

Tool icon now has a status-dot badge, and the tooltip lists
display name, tool, project, context, and status.
2026-06-16 11:13:10 +00:00
Developer a2f0847aad Merge branch 'fix/terminal-session-callbacks' into dev 2026-06-15 13:53:15 +00:00
Developer 82ff8b8801 fix: route terminal callbacks and status per session
The recent redraw fix keeps all xterm instances mounted (display:none)
when switching sessions. However, sendData/focus/font-size refs and the
header connection status were still stored globally, so the last-mounted
hidden session could own keyboard input, font-size buttons, and the
status dot for the active session.

- Pass sessionId to onTerminalReady from TerminalComponent.
- Store terminal callbacks and status keyed by sessionId in use-terminal-page.
- Use activeSessionId to route special-key input, font-size changes, and header status.
- Clean up per-session refs and status when sessions are closed.
- Update MobileTerminalWrapper signature for the new callback shape.

Quality gates: npm run typecheck, npm run lint, npm test (87 passed)
2026-06-15 13:53:09 +00:00
Developer a6ad8268b9 fix: preserve terminal buffer across tab switches and focus regain
Render all terminal sessions and hide inactive ones with display:none so
xterm instances are no longer unmounted/remounted when switching tabs.

Remove terminal.clear() on the connected status message: the server keeps
the session buffer, and clearing was erasing visible content when the tab
regained focus or reconnected.

- Keep TerminalComponent instances alive in both Desktop and Mobile views.
- Only refit/resize on reconnect instead of clearing.

Closes terminal redraw on focus regain.
2026-06-15 13:11:08 +00:00
Developer 3358af57c2 fix: stack profile file mounts onto profile git-mounts to avoid masking
When a config profile declares both a git_mount and a mounts entry for the
same directory (e.g. ~/.pi), the generated bind-mounts would mask each other
inside the container. Instead, copy the static profile files into the
instance-scoped git-mount source directory so the container sees both the
cloned repo contents and the static files through a single bind-mount.

- Add _stack_profile_mounts_with_git_mounts helper to merge overlapping
  profile mounts into git-mount sources.
- Integrate stacking into start_tool_instance after resolving both mount
  types.
- Add unit tests for exact, descendant, non-overlapping, and file cases.
2026-06-15 12:50:19 +00:00
Developer 6a61669294 fix: git repo mount race and path resolution
- Include branch in git-mount clone dir hash so different branches of the
  same repo get separate directories and no longer race.
- Resolve profile working_directory before git mounts so relative targets
  are not silently skipped.
- Fall back to tool_type.home_directory for non-manifest tools.
- Fix glob target calculation to avoid duplicate directory segment.
- Add exc_info logging for git mount clone failures.
2026-06-15 12:15:50 +00:00
Developer 29e48cdb65 fix: force interactive login shell for bash containers
Detached containers with tty: true still exited immediately because a
plain /bin/bash invocation exits with code 0 when stdin is not connected.

- Detect when the container CMD is /bin/bash or bash and exec an
  interactive login shell () after dropping privileges
- Keep the generic  path for non-shell commands
- Bump compiler_version to v4 to force a fresh image build

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 11:18:28 +00:00
Developer ed1f7805f6 fix: bump compiler version to v3 for runuser entrypoint
The previous compiler_version v2 already produced an image tag
(3d34c636) for the su-based fix. Images built under v2 still had the
broken su entrypoint that exited immediately. Bump to v3 so the runuser
change forces a fresh image build with the corrected privilege drop.

Quality gates:
- pytest tests/unit: 219 passed
2026-06-15 11:09:20 +00:00
Developer 1d345eba32 fix: use runuser for privilege drop and capture container stderr in logs
The container still exited immediately after the su-based fix.  can
interfere with TTY/stdin handling for interactive shells. Switch to
, which is root-only, skips PAM, and preserves file descriptors so
bash stays interactive.

Also improve container failure diagnostics:
-  now combines stdout and stderr
- This helps surface the real reason when a container exits with code 0

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 10:41:37 +00:00
Developer e35e605914 fix: bump manifest image tag when compiler logic changes
compute_image_tag hashed only the manifest JSON, so cached images built
before the entrypoint fix were reused even though compile_entrypoint() now
produces a different entrypoint. This caused containers to keep using the
old (broken) entrypoint that exited immediately.

- Include a compiler_version token in the hash input so manifest compiler
  changes invalidate previously built images
- This forces a fresh image build for new instances after any change to
  compile_dockerfile, compile_entrypoint, or compile_compose

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 10:22:36 +00:00
Developer 94137c6586 fix: use login shell when dropping privileges in manifest entrypoint
The generated entrypoint used a brittle  invocation that could
spawn a non-interactive bash and exit immediately, causing containers to
stop with exit code 0 right after startup.

- Switch to  so the
  container user gets a login shell and stdin/tty are preserved
- Update the unit test assertion for the new drop-privileges command

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 10:18:52 +00:00
Developer 1658767cf4 fix: clone workspace into repo-named subdirectory directly
Git clone with an explicit destination puts the working copy directly into
that directory; it does not create a repo-named subdirectory. The previous
change assumed the opposite, so workspaces ended up at
/data/working-copies/{workspace_id}/ with the repo contents mixed in,
causing a 500 when the expected repo-named subdirectory was missing.

- Build the target path as /data/working-copies/{workspace_id}/{repo_name}/
  and pass it directly to GitService.clone
- Remove stale directory detection and fallback logic that is no longer
  needed
- Keep diagnostic logging around git clone failures

Quality gates:
- pytest tests/unit: 219 passed
- mypy: clean on changed files
2026-06-15 10:09:30 +00:00
Developer 83928d0f02 chore: add diagnostic logging around workspace git clone
The workspace creation endpoint returns 500 but the actual error is not
visible. Add explicit error logging when GitService.clone fails and info
logging when git creates a directory name different from the one derived
from the remote URL.

Quality gates:
- pytest tests/unit: 219 passed
- mypy: clean on changed files
2026-06-15 10:04:34 +00:00
Developer 8f648264f1 fix: surface real workspace creation errors instead of generic 409
The create-workspace endpoints caught every exception and returned 409
"Workspace name already exists", hiding the actual failure (e.g. git
clone errors, remote URL problems, or filesystem issues).

- Distinguish ValueError -> 400, unexpected exceptions -> 500 with detail
- Preserve HTTPException re-raise for existing FastAPI error paths

Quality gates:
- pytest tests/unit: 219 passed
- mypy: clean on changed files
2026-06-15 09:46:55 +00:00
Developer b26ed7c3e4 refactor: store workspaces as {workspace_id}/{repo_name} for natural git clone layout
Working copies were stored as /data/working-copies/{repo_id}/{workspace_name}/,
so git clone was forced into a user-named directory. That meant the container
mount basename was the workspace name (e.g. main) instead of the repo name.

- Generate the workspace UUID before cloning and clone into
  /data/working-copies/{workspace_id}/ so git creates {repo_name}/ naturally
- Set workspace.path to /data/working-copies/{workspace_id}/{repo_name}/
- Update _migrate_clone_into_workspace() to use the same layout
- _get_repository_mount_name() now prefers workspace.path basename and only
  falls back to remote URL / repo.name for legacy repo-only instances
- Update unit tests to assert workspace path basename is used for mounts

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 09:40:59 +00:00
Developer 6e33e8e4e9 fix: remove explicit repo mount from pi-agent manifest and derive workspace name from remote URL
The pi-agent manifest still declared an explicit repo mount with
{{WORKSPACE_NAME}}, making the mount target dependent on tool config. The
instance service now synthesizes the repo mount, so the manifest no longer
needs the explicit mount.

- Add Alembic migration 2026_06_15_090500 to remove the source_type: repo
  mount from the built-in pi-agent manifest
- Add _get_repository_mount_name() helper to derive the workspace directory
  name from the repository remote URL (matching git clone behavior) and
  fall back to the user-provided repository name
- Use the helper for WORKSPACE_NAME/REPO_NAME in manifest, legacy dockerfile,
  and legacy compose template paths
- Update unit tests for the new migration and helper

Quality gates:
- pytest tests/unit: 218 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
2026-06-15 09:10:05 +00:00
Developer f0ae9483f3 fix: use repository name for workspace mount target
WORKSPACE_NAME was computed from os.path.basename(repo_path), so when a
workspace path ended in a directory like 'main', the container mount target
became /home/user/main instead of /home/user/{repo-name}.

- Use GitRepository.name for WORKSPACE_NAME/REPO_NAME in manifest and
  legacy dockerfile flows
- Add unit test verifying prepare_manifest_instance uses repo.name even
  when the workspace path basename differs

Quality gates:
- pytest tests/unit: 213 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 08:54:01 +00:00
Developer 90992e46a8 fix: remove stale {{WORKSPACE_NAME}} directory from container home
Older cached images still contain a literal /home/user/{{WORKSPACE_NAME}}
directory baked in by the previous Dockerfile generation. Even though new
images no longer create it, existing images leave the placeholder folder
alongside the real repo-named mount.

- Add entrypoint cleanup that removes /{{WORKSPACE_NAME}} if it
  exists before creating the real workspace target and /workspace symlink
- Update unit tests to assert the stale placeholder removal

Quality gates:
- pytest tests/unit: 212 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 08:41:27 +00:00
Developer 41f9427224 fix: avoid literal {{WORKSPACE_NAME}} directories in built images
When a manifest mount target uses ~/{{WORKSPACE_NAME}}, the Dockerfile was
building a literal directory named {{WORKSPACE_NAME}} into the image and
creating a broken /workspace symlink. The runtime mount then created the
correct repo-named folder alongside the placeholder folder.

- Only create static mount target directories in the Dockerfile; skip any
  target containing {{WORKSPACE_NAME}}
- Only create the /workspace compatibility symlink at image-build time when
  the workspace name is known; otherwise let the entrypoint create it from
  the WORKSPACE_NAME environment variable
- Update unit tests to cover both build-time workspace names and runtime
  placeholders

Quality gates:
- pytest tests/unit: 211 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-15 08:29:03 +00:00
Developer 089d802f1d fix: prevent failed containers from showing as running on dashboard
- Add final get_container_status check in start_tool_instance before
  writing status=running; mark as error and return logs if container stopped
- Treat restarting as error in HealthMonitor when DB status was already
  running, so crash loops are surfaced instead of preserved
- Disable auto-restart (restart: unless-stopped -> restart: no) for tool
  instances in manifest compiler, legacy dockerfile path, and built-in seeds

Quality gates:
- pytest tests/unit: 210 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-14 21:52:02 +00:00
Developer a4e6c46a47 fix: run manifest containers as root and drop privileges in entrypoint
The compose file was forcing the container to run as uid 1001, so the
entrypoint could not create /workspace even with sudo configured.

- Remove Dockerfile USER directive so containers start as root
- Make compile_compose use user: 0:0 when the manifest declares a user
- Make the entrypoint drop to the container user via  after setup,
  preserving environment variables and command arguments
- Update unit tests to assert root startup and privilege drop

Quality gates:
- pytest tests/unit: 210 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-14 21:32:27 +00:00
Developer 47de2a0133 fix: check root before sudo when creating /workspace symlink
The previous ordering checked SUDO before checking if the process was
already running as root. When Docker starts the container with a
non-root user, SUDO may be empty, but the real fix is that the
entrypoint should try root first (e.g. when the image is started as
root) and only then fall back to sudo.

- Reorder symlink creation logic: root first, then sudo, then best-effort
- Update unit test to assert root is checked before sudo

Quality gates:
- pytest tests/unit: 208 passed
- ruff: clean on changed files
- mypy: clean on changed files
2026-06-14 21:25:41 +00:00
Developer bd94cc9bbf fix: use sudo/root to create /workspace symlink in manifest entrypoint
The previous commit moved the pi-agent repo mount from /workspace to
/home/user/{repo_name}. This exposed a permission bug: the Dockerfile
creates /workspace as a root-owned symlink in the image, and the
non-root entrypoint could not replace it because / is owned by root.

- Update compile_entrypoint to recreate /workspace via sudo when running
  as the container user, or directly when running as root
- Add unit test covering sudo/root symlink creation
- Update OpenSpec change docs with the additional root cause

Quality gates:
- pytest tests/unit: 208 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
2026-06-14 20:29:03 +00:00
Developer fe82a248ec fix: pi container repo mount target and npm update permissions
- Add Alembic migration to update built-in pi-agent manifest:
  * repo mount target from /workspace to ~/{{WORKSPACE_NAME}}
  * keep /workspace as compatibility symlink via working_dir
  * update startup chown target to $HOME/$WORKSPACE_NAME
- Pass REPO_NAME and WORKSPACE_NAME to compile_compose from instance_service
- Substitute {{WORKSPACE_NAME}} in manifest mount targets and expose it as
  a container env var so the entrypoint can create the /workspace symlink
- Generate entrypoint workspace symlink from runtime WORKSPACE_NAME env var
- Install npm_global packages into {home_dir}/.npm-global with PATH so the
  non-root container user can update global packages
- Update manifest compiler unit tests for the new behavior

Quality gates:
- pytest tests/unit: 207 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
2026-06-14 18:45:32 +00:00
Developer c8db6ce933 fix: disable native touch panning on mobile terminal and archive specs
- Change mobile terminal CSS to use touch-action: none and
  overscroll-behavior: none so the custom touch handler owns swipes
- Archive completed/partial OpenSpec specs to
  openspec/changes/archive/2026-06-14-completed-specs-archive/
- Regenerate project maps

Quality gates: npm run typecheck, npm run lint (apps/web)
2026-06-14 18:07:01 +00:00
Developer 896674195c Merge branch 'feat/tool-container-home-directory' into dev 2026-06-14 13:10:10 +00:00
Developer ddd92e3dd4 feat: implement configurable tool container home directory
- Add ToolType.home_directory column with default /home/user
- Add Alembic migration to add column, set existing rows, and rewrite
  /workspace to /home/user/{{WORKSPACE_NAME}} in legacy templates
- Add merge migration fc8f1a20cbf6 to resolve Alembic multiple heads
- Update manifest compiler to honor manifest.home_directory for HOME,
  WORKDIR, /workspace symlink, and default repo mount target
- Update legacy dockerfile/compose instance generation to use
  tool_type.home_directory
- Thread resolved home_dir through config profile and git mount expansion
- Generate entrypoint permission fixer to chown home/mounts at startup
- Update base.dockerfile with sudo/passwordless sudo for permission fixer
- Add unit tests for manifest compiler, instance service, and migrations
- Add placeholder integration test for container lifecycle
- Update openspec/tasks/home-path-expansion.md task checkboxes
- Update project maps for modified files

Quality gates: py_compile, ruff, mypy, pytest tests/unit (205 passed),
pytest tests/integration (110 passed, 35 skipped). Alembic round-trip
and container lifecycle integration tests require Docker/PostgreSQL.
2026-06-14 13:09:41 +00:00
Developer b4203a4a09 chore: update project maps for tool container home directory artifacts 2026-06-14 10:32:13 +00:00
Developer 5fc8e035e6 docs: tool container home directory design, plan, and test plan
- Add design doc / ADR for configurable /home/user home directory
   - Add implementation plan with phased rollout
   - Add test plan / QA checklist
   - Update OpenSpec task for home-path-expansion
2026-06-14 10:23:59 +00:00
Developer e6114ed18c Merge branch 'fix/terminal-container-overflow' into dev 2026-06-14 09:08:25 +00:00
Developer ac9f7a9299 fix: prevent terminal container from overflowing page on desktop
The desktop terminal page sometimes grew an outer scrollbar because the
terminal instance/wrapper/container chain lacked height constraints.
Without min/max-height enforcement, xterm.js's internal viewport could
expand its parent flex/grid track past the available space.

- Add overflow: hidden to .terminal-page.
- Add max-height: 100% and overflow: hidden to .terminal-instance.
- Add max-height: 100% to .terminal-wrapper.
- Add min-height: 0 to .terminal-container.
- Constrain .xterm-viewport to max-height/width 100% so it fills but
  never exceeds its container.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed).

Refs: openspec/changes/fix-terminal-container-overflow
2026-06-14 09:07:43 +00:00
Developer 84cf423684 Merge branch 'fix/tmux-mouse-config' into dev 2026-06-14 08:49:57 +00:00
Developer 4c14966ae3 fix: write valid multi-line tmux config in Pi Agent images
The Pi Agent dockerfile templates created ~/.tmux.conf with a literal
\n because the RUN command used single-quoted echo. Tmux never parsed
the malformed line, so mouse mode stayed off. Without tmux mouse mode,
mouse-wheel events in xterm.js fell back to Up/Down arrow keys and
cycled shell command history instead of scrolling the terminal buffer.

- Use printf '%s\n' to write real newlines in .tmux.conf.
- Apply the same fix to the ranger rc.conf where the same bug existed.
- Update tool-images/pi-agent.dockerfile and both affected alembic
  migration dockerfile strings.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed),
py_compile on changed migrations.

Refs: openspec/changes/fix-tmux-mouse-config
2026-06-14 08:48:39 +00:00
Developer 6c31ef9577 Merge branch 'feat/mobile-list-delete-button' into dev 2026-06-14 08:12:21 +00:00
Developer a12d6a8169 feat: restore delete buttons in mobile list views and fix edit action bar
- Wire MobileListView onItemDelete/onItemDuplicate callbacks to render
  action buttons in each list row.
- Pass onItemDelete in ToolWorkshopMobileView list view.
- Add CSS for mobile-list-item-action buttons.
- Fix MobileEditView sticky bottom action bar that was hidden behind
  the 64px mobile navigation bar; raise to bottom: 64px and z-index 110.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)

Refs: openspec/changes/mobile-list-delete-button
2026-06-14 08:10:58 +00:00
Developer 13b3c60abc Merge branch 'feat/mobile-edit-default-bottom-actions' into dev 2026-06-13 22:34:48 +00:00
Developer 23fc0a6b82 feat: mobile edit-as-default with sticky save/delete actions
- Update MobileEditView to render Save and optional Delete in a
  sticky bottom action bar; header now shows Cancel + title only.
- Make ConfigProfilesMobileView open edit view on profile tap.
- Make ToolWorkshopMobileView open edit view on tool type tap.
- Wire delete into MobileEditView for existing profiles and tool
  types.
- Stay on edit view after saving an existing item; create flow
  returns to list as before.
- Update ToolWorkshopPage cancel to return to list.
- Add mobile-edit-actions and mobile-edit-delete CSS.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)

Refs: openspec/changes/mobile-edit-default-bottom-actions
2026-06-13 22:29:15 +00:00
Developer 8b15689fb3 Merge branch 'feat/mobile-config-profiles-ui' into dev 2026-06-13 22:10:06 +00:00
Developer 98d4393387 feat: redesign mobile Config Profiles detail, preview, and edit pages
- Rewrite ConfigProfilesMobileView to match desktop functionality:
  full detail view with all fields, preview action showing resolved
  profile, and edit view with project/tool selects, includes,
  environment variables, runtime hints, files, mounts with nested
  files, and git mounts.
- Update useConfigProfiles.handleSubmit to return boolean success.
- Update ConfigProfilesPage to pass required state and callbacks.
- Add mobile-specific CSS for config profile forms, includes,
  mount/file cards, and preview panels.
- Allow MobileDetailView to render extra children.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)

Refs: openspec/changes/mobile-config-profiles-ui
2026-06-13 22:04:17 +00:00
Developer 35f0a3ea2e Merge branch 'fix/mobile-profile-title' into dev 2026-06-13 21:45:33 +00:00
Developer 1ae8d0e45f fix: hide page title on mobile Profile view
The mobile profile form already renders its own header via
ProfileMobileView, so the desktop page title was redundant on small
viewports.

Quality gates: npm run typecheck, npm run lint

Refs: openspec/changes/mobile-tool-profile-ui
2026-06-13 21:45:33 +00:00
Developer 025ccc5817 Merge branch 'feat/mobile-tool-profile-ui' into dev 2026-06-13 21:41:51 +00:00
Developer 350b393457 feat: rework mobile UI for Tool Workshop and Profile pages
- Rework ToolWorkshopMobileView to support full desktop functionality:
  definition type selection (Compose/Dockerfile/Manifest), manifest editor,
  conditional port, startup command, readiness probe, required variables,
  and validation feedback.
- Add ProfileMobileView and wire ProfilePage to render it on mobile.
- Update useToolWorkshop hook to return boolean success from submit.
- Add responsive CSS for mobile forms, edit views, and manifest editor.
- Update project maps.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)

Refs: openspec/changes/mobile-tool-profile-ui
2026-06-13 21:41:42 +00:00
717 changed files with 13597 additions and 6285 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides.
## role
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling.
## parent
-
## children
+2 -2
View File
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides.
## role
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling.
## files
- .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment
- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git
@@ -31,7 +31,7 @@ Infrastructure and deployment configuration package for a self-hosted project ma
- progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases
- swap-pane | Empty file with no functionality
## arch
Docker Compose-based microservices architecture with frontend/backend separation, PostgreSQL/Redis data layer, Traefik reverse proxy integration, and environment-driven configuration management following twelve-factor app principles.
Containerized microservices architecture using Docker Compose with PostgreSQL and Redis data layers, Traefik reverse proxy for TLS/ingress, multi-stage builds for Node.js frontend and Python backend, and environment-driven configuration management.
## tags
docker, redis, git, application, postgresql, compose, traefik, project
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps
## role
Contains the main deployable application modules or entry points for the project.
Container for deployable application entry points and top-level configurations in the project.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps
index: apps/.pi-map.index.md
## role
Contains the main deployable application modules or entry points for the project.
Container for deployable application entry points and top-level configurations in the project.
## files
## arch
Modular monolith or microservices architecture with separate application boundaries, each potentially having its own configuration, dependencies, and lifecycle.
Monorepo-style directory structure housing independently runnable applications that share common libraries or modules, typically with per-app configuration, dependencies, and build targets.
## tags
-
## symbols
+7 -1
View File
@@ -2,17 +2,23 @@
dir: apps/api
## role
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools by orchestrating Docker instances for remote development environments.
## parent
index: apps/.pi-map.index.md
map: apps/.pi-map.md
## children
- apps/api/.mypy_cache
index: apps/api/.mypy_cache/.pi-map.index.md
map: apps/api/.mypy_cache/.pi-map.md
- apps/api/.pi-lens
index: apps/api/.pi-lens/.pi-map.index.md
map: apps/api/.pi-lens/.pi-map.md
- apps/api/.pytest_cache
index: apps/api/.pytest_cache/.pi-map.index.md
map: apps/api/.pytest_cache/.pi-map.md
- apps/api/.ruff_cache
index: apps/api/.ruff_cache/.pi-map.index.md
map: apps/api/.ruff_cache/.pi-map.md
- apps/api/.venv-test
index: apps/api/.venv-test/.pi-map.index.md
map: apps/api/.venv-test/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api
index: apps/api/.pi-map.index.md
## role
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools by orchestrating Docker instances for remote development environments.
## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid copying unnecessary files into containers. | dep: Docker
- Dockerfile | Multi-stage Docker build for a Python application with Docker socket access, Cloudflare tunneling, and database dependency waiting | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
@@ -14,7 +14,7 @@ Self-hosted FastAPI backend API that manages projects, git repositories, and dev
- uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages
- wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep
## arch
Async Python/FastAPI with PostgreSQL (Alembic migrations), multi-stage Docker deployment with Cloudflare tunneling, uv package management, and containerized service orchestration.
Async Python backend using FastAPI with PostgreSQL (asyncpg), Alembic migrations, uv package management, multi-stage Docker builds with Docker-in-Docker socket access, and Cloudflare tunneling for secure external connectivity.
## tags
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/alembic
## role
Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
Database migration infrastructure for the API application, providing version-controlled schema changes with async SQLAlchemy support.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
+2 -2
View File
@@ -4,12 +4,12 @@ dir: apps/api/alembic
index: apps/api/alembic/.pi-map.index.md
## role
Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
Database migration infrastructure for the API application, providing version-controlled schema changes with async SQLAlchemy support.
## files
- env.py | Configures Alembic database migration environment with async SQLAlchemy support for a project. | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQLAlchemy schema migrations | dep: alembic, sqlalchemy
## arch
Alembic migration framework with Mako templating for generating revision scripts, async SQLAlchemy engine configuration, and autogenerate capabilities for schema change tracking.
Alembic migration framework with Mako templating for generating migration scripts, configured for async SQLAlchemy operations.
## tags
migrations, run, sqlalchemy, async, alembic, call:context.configure, call:context.begin, transaction
## symbols
+10 -2
View File
@@ -2,12 +2,14 @@
dir: apps/api/alembic/versions
## role
Manages incremental database schema evolution for the API application using Alembic migrations, tracking all table creations, column additions, relationship changes, and data transformations over the project's lifecycle.
Database schema version control and migration management for the API application, tracking incremental schema changes from initial tables through advanced features like workspaces, manifests, and monitoring.
## parent
index: apps/api/alembic/.pi-map.index.md
map: apps/api/alembic/.pi-map.md
## children
-
- apps/api/alembic/versions/.ruff_cache
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
map: apps/api/alembic/versions/.ruff_cache/.pi-map.md
## files
- 0001_initial_schema.py
- 0002_refresh_tokens.py
@@ -48,6 +50,9 @@ map: apps/api/alembic/.pi-map.md
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py
- 2026_06_01_add_workspaces.py
- 2026_06_13_make_clone_mode_nullable.py
- 2026_06_14_104415_add_tool_type_home_directory.py
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
- 398082499c30_add_tool_config_fields.py
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
@@ -55,6 +60,7 @@ map: apps/api/alembic/.pi-map.md
- 8ed7dd80973d_create_config_folders_table.py
- af8512103d67_add_tool_type_fields.py
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py
## links
index: apps/api/alembic/versions/.pi-map.index.md
map: apps/api/alembic/versions/.pi-map.md
@@ -65,5 +71,7 @@ map: apps/api/alembic/versions/.pi-map.md
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
- change versions config
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
- explore versions subdirectories
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
## dirty
-
+13 -7
View File
@@ -4,7 +4,7 @@ dir: apps/api/alembic/versions
index: apps/api/alembic/versions/.pi-map.index.md
## role
Manages incremental database schema evolution for the API application using Alembic migrations, tracking all table creations, column additions, relationship changes, and data transformations over the project's lifecycle.
Database schema version control and migration management for the API application, tracking incremental schema changes from initial tables through advanced features like workspaces, manifests, and monitoring.
## files
- 0001_initial_schema.py | Defines the initial database schema migration creating five tables (users, ssh_keys, projects, git_repositories, user_configs) with relationships, indexes, and constraints using Alembic. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication token management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
@@ -24,7 +24,7 @@ Manages incremental database schema evolution for the API application using Alem
- 0014_merge_heads.py | Alembic merge migration that reconciles two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
- 0015_single_interface.py | Alembic database migration that replaces a JSON array `interfaces` column with `interface_type` string and `requires_port` boolean columns in the `tool_types` table, with dialect-specific data migration for PostgreSQL and SQLite. | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql (dialect)
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds a nullable UUID foreign key column `ssh_key_id` to the `config_profiles` table referencing `ssh_keys.id` with SET NULL on delete | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a "pi-agent" terminal-based coding tool type to a tool_types table with Docker configuration templates | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a new "pi-agent" tool type with terminal-based Docker environment for the Pi coding agent | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
- 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories table and clone_mode/branch columns to tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 2026_05_23_remove_is_builtin.py | Alembic database migration to remove the `is_builtin` column from the `tool_types` table | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
@@ -33,7 +33,7 @@ Manages incremental database schema evolution for the API application using Alem
- 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories table to support external repositories and expands alembic_version version_num column to 64 characters. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
- 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health checks | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with tracking columns and foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest-related columns to tool_types and tool_instances, and migrates the pi-agent tool from Dockerfile-based to manifest-based definitions with seed data. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest support to existing tool_types and tool_instances tables, and seeds initial data with a base Ubuntu image and pi-agent manifest while migrating the legacy pi-agent from Dockerfile templates to the new manifest system. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
- 2026_05_28_drop_tool_configs_and_config_folders.py | Alembic database migration that drops `tool_configs` and `config_folders` tables with conditional existence checks and full downgrade recreation | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
- 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed tracking and optimized querying indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON column named ssh_key_ids to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
@@ -44,18 +44,22 @@ Manages incremental database schema evolution for the API application using Alem
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database tool_types records and on-disk instance compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy, pathlib.Path, information_schema
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (or restores) the ssh_keys mount from a JSON manifest stored in the tool_definition_manifests table for the pi-agent tool definition. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
- 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with foreign keys to git_repositories and users, adds indexes, and adds a workspace_id column to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
- 2026_06_13_make_clone_mode_nullable.py | Alembic database migration that makes the `clone_mode` column in `tool_instances` table nullable to allow NULL values for new rows | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
- 2026_06_13_make_clone_mode_nullable.py | Alembic database migration that makes the `clone_mode` column in `tool_instances` table nullable to support workspace-first cleanup workflow. | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
- 2026_06_14_104415_add_tool_type_home_directory.py | Alembic database migration that adds a `home_directory` column to `tool_types` table and updates template strings to use a configurable workspace path instead of hardcoded `/workspace` | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, func:downgrade() → None, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, call:op.drop_column | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py | Alembic database migration that updates the pi-agent tool definition manifest to mount repositories under the home directory instead of /workspace | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py | Alembic database migration that removes explicit repo mounts from the pi-agent tool definition manifest and synthesizes them via compile_compose instead | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:any, call:mount.get, call:mounts.append, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds five new columns (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two parallel revision branches (removing is_builtin and adding config_profiles) into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Alembic database migration that merges two divergent migration branches (profile resolver and workspaces) into a single head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes pi_state and pi_config mounts from the pi-agent manifest in upgrade, and restores them in downgrade | exp: func:_load_manifest(manifest_json), call:isinstance, call:json.loads, func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:m.get, call:mounts.append, call:json.dumps | dep: json, alembic, sqlalchemy
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes or restores pi_state and pi_config mounts from the pi-agent manifest stored in tool_definition_manifests table | exp: func:_load_manifest(manifest_json), call:isinstance, call:json.loads, func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:m.get, call:mounts.append, call:json.dumps | dep: json, alembic, sqlalchemy
- 8ed7dd80973d_create_config_folders_table.py | Alembic database migration that creates a config_folders table with user-owned configuration folders supporting JSONB file storage and project overrides | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds new columns (definition_type, dockerfile_template, build_context, readiness_probe) to the tool_types table with a CHECK constraint on definition_type. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two prior revisions (single_interface and clone_mode) into a single migration path | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py | Alembic database migration that merges two divergent migration branches (home directory cleanup and pi agent mount cleanup) into a single revision history | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
## arch
Linear and branched migration pattern using Alembic's revision system with merge migrations to reconcile divergent branches; each migration is an imperative upgrade/downgrade script containing raw SQL/DDL operations, with some migrations including data seeding and dialect-specific logic (PostgreSQL/SQLite), but lacks consistent naming convention (mixed timestamp and numeric prefixes) indicating organic evolution rather than planned schema design.
Linear and branched migration history using Alembic's revision system with merge points to reconcile divergent branches; migrations use declarative SQLAlchemy operations with defensive idempotent checks, conditional existence guards, dialect-specific handling (PostgreSQL/SQLite), and in-place data migrations for template/schema evolution.
## tags
column, table, call:op.drop, downgrade, alembic, upgrade, key, call:sa.text
column, table, call:op.drop, alembic, downgrade, upgrade, key, call:sa.text
## symbols
- upgrade
- downgrade
@@ -72,5 +76,7 @@ column, table, call:op.drop, downgrade, alembic, upgrade, key, call:sa.text
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
- change versions config
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
- explore versions subdirectories
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
## dirty
-
@@ -104,11 +104,11 @@ RUN git config --global init.defaultBranch main \\
&& git config --global user.name "Developer"
# Create default tmux config
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \\
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
@@ -308,10 +308,10 @@ RUN git config --global init.defaultBranch main \\
&& git config --global user.email "dev@headquarter.local" \\
&& git config --global user.name "Developer"
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
RUN mkdir -p /home/user/.config/ranger \\
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
RUN mkdir -p /home/user/.pi/agent
@@ -0,0 +1,89 @@
"""add_tool_type_home_directory
Revision ID: 2026_06_14_104415
Revises: f3d2dc90ba3a
Create Date: 2026-06-14 10:44:15.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_14_104415"
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_types = table(
"tool_types",
column("id", sa.UUID),
column("home_directory", sa.String),
column("compose_template", sa.Text),
column("dockerfile_template", sa.Text),
)
OLD_WORKSPACE = "/workspace"
NEW_WORKSPACE = "/home/user/{{WORKSPACE_NAME}}"
def upgrade() -> None:
# Add the new column with a default that applies to existing rows.
op.add_column(
"tool_types",
sa.Column(
"home_directory",
sa.String(255),
nullable=False,
server_default="/home/user",
),
)
# Rewrite legacy templates that mount the workspace at /workspace so they
# use the new configurable home directory and preserve the workspace name.
op.execute(
sa.update(tool_types)
.where(tool_types.c.compose_template.is_not(None))
.values(
compose_template=sa.func.replace(
tool_types.c.compose_template, OLD_WORKSPACE, NEW_WORKSPACE
)
)
)
op.execute(
sa.update(tool_types)
.where(tool_types.c.dockerfile_template.is_not(None))
.values(
dockerfile_template=sa.func.replace(
tool_types.c.dockerfile_template, OLD_WORKSPACE, NEW_WORKSPACE
)
)
)
def downgrade() -> None:
# Restore the original /workspace strings before dropping the column.
op.execute(
sa.update(tool_types)
.where(tool_types.c.compose_template.is_not(None))
.values(
compose_template=sa.func.replace(
tool_types.c.compose_template, NEW_WORKSPACE, OLD_WORKSPACE
)
)
)
op.execute(
sa.update(tool_types)
.where(tool_types.c.dockerfile_template.is_not(None))
.values(
dockerfile_template=sa.func.replace(
tool_types.c.dockerfile_template, NEW_WORKSPACE, OLD_WORKSPACE
)
)
)
op.drop_column("tool_types", "home_directory")
@@ -0,0 +1,90 @@
"""fix_pi_agent_home_directory_mount
Revision ID: 2026_06_14_182955
Revises: fc8f1a20cbf6
Create Date: 2026-06-14 18:29:55.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_14_182955"
down_revision: Union[str, Sequence[str], None] = "fc8f1a20cbf6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_definition_manifests = table(
"tool_definition_manifests",
column("id", sa.UUID),
column("name", sa.String),
column("manifest", sa.JSON),
)
def _find_pi_agent_manifest(conn: sa.Connection) -> tuple[Union[str, None], Union[dict, None]]:
result = conn.execute(
sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest)
.where(tool_definition_manifests.c.name == "pi-agent")
).fetchone()
if result is None:
return None, None
return result.id, dict(result.manifest)
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
conn.execute(
sa.update(tool_definition_manifests)
.where(tool_definition_manifests.c.id == manifest_id)
.values(manifest=manifest)
)
def upgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
# Mount the repo under the configured home directory, preserving the repo
# directory name via the WORKSPACE_NAME runtime variable.
for mount in manifest.get("mounts", []):
if mount.get("source_type") == "repo":
mount["target"] = "~/{{WORKSPACE_NAME}}"
# Keep /workspace as a compatibility symlink to the real mount path.
runtime = manifest.setdefault("runtime", {})
runtime["working_dir"] = "/workspace"
# Update the startup script to chown the real mount path.
scripts = manifest.setdefault("scripts", {})
scripts["startup"] = [
'if [ -n "$WORKSPACE_NAME" ]; then sudo chown -R user:user "$HOME/$WORKSPACE_NAME" 2>/dev/null || true; fi',
]
_update_manifest(conn, manifest_id, manifest)
def downgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
for mount in manifest.get("mounts", []):
if mount.get("source_type") == "repo":
mount["target"] = "/workspace"
runtime = manifest.setdefault("runtime", {})
runtime["working_dir"] = "/workspace"
scripts = manifest.setdefault("scripts", {})
scripts["startup"] = [
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
]
_update_manifest(conn, manifest_id, manifest)
@@ -0,0 +1,86 @@
"""remove pi agent explicit repo mount
Revision ID: 2026_06_15_090500
Revises: 2026_06_14_182955
Create Date: 2026-06-15 09:05:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = "2026_06_15_090500"
down_revision: Union[str, Sequence[str], None] = "2026_06_14_182955"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
tool_definition_manifests = table(
"tool_definition_manifests",
column("id", sa.UUID),
column("name", sa.String),
column("manifest", sa.JSON),
)
def _find_pi_agent_manifest(
conn: sa.Connection,
) -> tuple[Union[str, None], Union[dict, None]]:
result = conn.execute(
sa.select(
tool_definition_manifests.c.id, tool_definition_manifests.c.manifest
).where(tool_definition_manifests.c.name == "pi-agent")
).fetchone()
if result is None:
return None, None
return result.id, dict(result.manifest)
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
conn.execute(
sa.update(tool_definition_manifests)
.where(tool_definition_manifests.c.id == manifest_id)
.values(manifest=manifest)
)
def upgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
# The repo mount is now synthesized by compile_compose based on the
# instance's repository, so the manifest no longer needs an explicit
# repo mount with a {{WORKSPACE_NAME}} placeholder.
manifest["mounts"] = [
mount
for mount in manifest.get("mounts", [])
if mount.get("source_type") != "repo"
]
_update_manifest(conn, manifest_id, manifest)
def downgrade() -> None:
conn = op.get_bind()
manifest_id, manifest = _find_pi_agent_manifest(conn)
if not manifest_id or not manifest:
return
mounts = manifest.setdefault("mounts", [])
if not any(mount.get("source_type") == "repo" for mount in mounts):
mounts.append(
{
"name": "workspace",
"target": "~/{{WORKSPACE_NAME}}",
"source_type": "repo",
"writable": True,
"owner": "user",
}
)
_update_manifest(conn, manifest_id, manifest)
@@ -0,0 +1,23 @@
"""merge home directory and pi agent mount cleanup heads
Revision ID: fc8f1a20cbf6
Revises: 2026_06_14_104415, 8c6d1dbd4798
Create Date: 2026-06-14 11:08:41.273502
"""
# revision identifiers, used by Alembic.
revision = 'fc8f1a20cbf6'
down_revision = ('2026_06_14_104415', '8c6d1dbd4798')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src
## role
Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
Core application package for the Headquarter API, handling configuration, database connectivity, logging infrastructure, and FastAPI application lifecycle.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api/src
index: apps/api/src/.pi-map.index.md
## role
Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
Core application package for the Headquarter API, handling configuration, database connectivity, logging infrastructure, and FastAPI application lifecycle.
## files
- __init__.py | Marks the directory as a Python package for the Headquarter API.
- config.py | Defines application configuration settings with environment-based overrides using Pydantic, including database URLs, service domains, OAuth/Authentik integration, JWT/session settings, and computed properties for environment-specific behavior. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
@@ -12,7 +12,7 @@ Core API application package that initializes and configures the Headquarter Fas
- logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation
- main.py | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
## arch
Layered architecture using Pydantic for environment-based configuration, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation IDs, and FastAPI middleware/routing pattern for a service-oriented backend.
Layered architecture with Pydantic-based settings management, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation ID tracking, and modular FastAPI initialization with middleware pipeline.
## tags
src, database, logging, call:logger.info, api, middleware, fastapi, filter
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api
## role
Defines shared API infrastructure including reusable Pydantic validators for consistent input validation across API endpoints.
Defines the API routing package and shared validation utilities for the REST API layer.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+2 -2
View File
@@ -4,12 +4,12 @@ dir: apps/api/src/api
index: apps/api/src/api/.pi-map.index.md
## role
Defines shared API infrastructure including reusable Pydantic validators for consistent input validation across API endpoints.
Defines the API routing package and shared validation utilities for the REST API layer.
## files
- __init__.py | Marks the directory as a Python package for API routers.
- shared_validators.py | Provides reusable Pydantic validator functions for API schema validation including mount paths, files, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
## arch
Modular utility package with functional validation helpers using Pydantic for declarative schema enforcement.
Modular package structure with reusable Pydantic validators for cross-cutting API schema concerns.
## tags
validate, raise:value, error, call:isinstance, mount, api, init, path
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api/workspace
## role
Provides FastAPI REST endpoints for workspace management, file operations, git integration, and tool instance management within user workspaces.
Provides FastAPI REST API endpoints for workspace management, including file operations, git version control, and tool instance orchestration within user-scoped development environments.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
+3 -3
View File
@@ -4,15 +4,15 @@ dir: apps/api/src/api/workspace
index: apps/api/src/api/workspace/.pi-map.index.md
## role
Provides FastAPI REST endpoints for workspace management, file operations, git integration, and tool instance management within user workspaces.
Provides FastAPI REST API endpoints for workspace management, including file operations, git version control, and tool instance orchestration within user-scoped development environments.
## files
- __init__.py | Aggregates and re-exports workspace API router modules for centralized access | dep: src.api.workspace.workspace_files, src.api.workspace.workspace_git, src.api.workspace.workspace_instances, src.api.workspace.workspaces
- workspace_files.py | Provides FastAPI endpoints for listing, reading, and writing files within user workspaces with optional git commit integration. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:list_files(workspace_id: uuid.UUID, path, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.list_directory, raise:HTTPException, func:get_file_content(workspace_id: uuid.UUID, path: str, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.read_file, raise:HTTPException, func:write_file(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:FileService, call:data.get("path", "").strip, call:data.get("message", "").strip, call:service.write_file, call:GitOperations, call:git.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.shared.file_service, sqlalchemy, src.services.git.git_operations
- workspace_git.py | Provides FastAPI REST endpoints for git operations (status, branches, commit, push, pull, fetch, checkout, history) scoped to user workspaces. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:git_status(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.status, raise:HTTPException, func:git_branches(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.branches, raise:HTTPException, func:git_commit(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("message", "").strip, call:GitOperations, call:git.commit, raise:HTTPException, func:git_push(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.push, raise:HTTPException, func:git_pull(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.pull, raise:HTTPException, func:git_fetch(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.fetch, raise:HTTPException, func:git_checkout(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("branch", "").strip, call:GitOperations, call:git.checkout, call:session.commit, raise:HTTPException, func:git_history(workspace_id: uuid.UUID, path, limit, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.history, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.git.git_operations, sqlalchemy
- workspace_instances.py | FastAPI router providing endpoints to create and list tool instances associated with a specific workspace. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:create_workspace_instance(workspace_id: uuid.UUID, data: CreateWorkspaceInstanceRequest, user_id, session) → dict, call:_get_workspace, call:session.get, call:CreateInstanceRequest, call:str, call:create_tool_instance, call:instance.created_at.isoformat, raise:HTTPException, func:list_workspace_instances(workspace_id: uuid.UUID, user_id, session) → list[dict], call:_get_workspace, call:session.execute, call:select(ToolInstance) .where(ToolInstance.workspace_id == workspace_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.tool, src.services.tool.instance_service
- workspaces.py | FastAPI router providing CRUD endpoints for workspace management with nested and top-level URL structures, including listing, creating, updating, deleting, and syncing workspaces tied to Git repositories. | exp: func:list_all_workspaces(user_id, session) → list[dict], call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, GitRepository, instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) .options(selectinload(GitRepository.project)) .where(Workspace.user_id == user_id) .order_by, call:instance_count.label, call:selectinload, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:delete_workspace_top_level(workspace_id: uuid.UUID, force, user_id, session) → dict, call:session.get, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:create_workspace_top_level(data: dict, user_id, session) → dict, call:data.get("repo_id", "").strip, call:uuid.UUID, call:session.get, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:list_workspaces(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_repo, call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, instance_count.label("instance_count"), ) .where(Workspace.repo_id == repo_id) .order_by, call:instance_count.label, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:create_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:get_workspace_detail(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:session.execute, call:select(func.count(ToolInstance.id)).where, call:func.count, call:result.scalar, call:str, call:workspace.last_sync_at.isoformat, call:workspace.created_at.isoformat, call:workspace.updated_at.isoformat, func:update_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:data.get("name", "").strip, call:data.get("branch", "").strip, call:session.commit, call:session.rollback, call:logger.error, call:str, raise:HTTPException, func:delete_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, force, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:sync_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.sync, call:session.commit, call:workspace.last_sync_at.isoformat, raise:HTTPException, func:_get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) → GitRepository, call:session.execute, call:select(GitRepository) .where( GitRepository.id == repo_id, GitRepository.project_id == project_id, ) .options, call:selectinload, call:result.scalar_one_or_none, raise:HTTPException, func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, repo_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.services.shared.workspace_manager
- workspaces.py | FastAPI router providing CRUD endpoints for managing Git repository workspaces with nested and top-level URL structures. | exp: func:list_all_workspaces(user_id, session) → list[dict], call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, GitRepository, instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) .options(selectinload(GitRepository.project)) .where(Workspace.user_id == user_id) .order_by, call:instance_count.label, call:selectinload, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:delete_workspace_top_level(workspace_id: uuid.UUID, force, user_id, session) → dict, call:session.get, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:create_workspace_top_level(data: dict, user_id, session) → dict, call:data.get("repo_id", "").strip, call:uuid.UUID, call:session.get, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:list_workspaces(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_repo, call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, instance_count.label("instance_count"), ) .where(Workspace.repo_id == repo_id) .order_by, call:instance_count.label, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:create_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:get_workspace_detail(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:session.execute, call:select(func.count(ToolInstance.id)).where, call:func.count, call:result.scalar, call:str, call:workspace.last_sync_at.isoformat, call:workspace.created_at.isoformat, call:workspace.updated_at.isoformat, func:update_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:data.get("name", "").strip, call:data.get("branch", "").strip, call:session.commit, call:session.rollback, call:logger.error, call:str, raise:HTTPException, func:delete_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, force, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:sync_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.sync, call:session.commit, call:workspace.last_sync_at.isoformat, raise:HTTPException, func:_get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) → GitRepository, call:session.execute, call:select(GitRepository) .where( GitRepository.id == repo_id, GitRepository.project_id == project_id, ) .options, call:selectinload, call:result.scalar_one_or_none, raise:HTTPException, func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, repo_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.services.shared.workspace_manager
## arch
Modular FastAPI router pattern with domain-driven separation (files, git, instances, workspaces) using nested URL structures and git-backed workspace synchronization.
Modular router-based FastAPI architecture with domain-driven separation of concerns (files, git, instances, workspaces) using nested URL routing patterns and explicit dependency injection for cross-cutting workspace context.
## tags
workspace, get, raise:httpexception, call:, at.isoformat, git, call:select, call:data.get
## symbols
+10 -4
View File
@@ -134,12 +134,18 @@ async def create_workspace_top_level(
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except HTTPException:
raise
except ValueError as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
status_code=500,
detail=f"Failed to create workspace: {exc}",
) from exc
await session.refresh(workspace)
@@ -241,8 +247,8 @@ async def create_workspace(
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
status_code=500,
detail=f"Failed to create workspace: {exc}",
) from exc
await session.refresh(workspace)
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/models
## role
Centralized database model definitions and shared infrastructure for the API's data layer.
Provides the SQLAlchemy ORM data models and database schema definitions for the API application.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+2 -2
View File
@@ -4,12 +4,12 @@ dir: apps/api/src/models
index: apps/api/src/models/.pi-map.index.md
## role
Centralized database model definitions and shared infrastructure for the API's data layer.
Provides the SQLAlchemy ORM data models and database schema definitions for the API application.
## files
- __init__.py | Re-exports model classes from submodules to provide a centralized public API for the src.models package | dep: src.models.base, src.models.config.config_profile, src.models.project.git_repository, src.models.project.project, src.models.project.workspace, src.models.system.health_check, src.models.system.instance_event, src.models.system.notification, src.models.system.terminal_session, src.models.tool.tool_definition_manifest, src.models.tool.tool_instance, src.models.tool.tool_type, src.models.user.ssh_key, src.models.user.user, src.models.user.user_config
- base.py | Defines SQLAlchemy base model and reusable mixins for UUID primary keys and automatic timestamp tracking in database models. | exp: class:Base, class:UUIDPrimaryKeyMixin, class:TimestampMixin | dep: uuid, datetime, sqlalchemy, sqlalchemy.orm
## arch
SQLAlchemy ORM with declarative base, mixin-based composition for cross-cutting concerns (UUIDs, timestamps), and explicit package-level re-exports for clean public API surface.
Layered repository pattern with declarative SQLAlchemy base, UUID/timestamp mixins for reusable model traits, and package-level facade pattern via __init__.py re-exports to centralize model access.
## tags
models, src, base, project, system, user, mixin, tool
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/models/tool
## role
Database models for containerized tool lifecycle management, covering tool definitions, deployment instances, and categorization types.
Provides SQLAlchemy ORM models for managing containerized tool definitions, types, and deployed instances in the API.
## parent
index: apps/api/src/models/.pi-map.index.md
map: apps/api/src/models/.pi-map.md
+4 -4
View File
@@ -4,16 +4,16 @@ dir: apps/api/src/models/tool
index: apps/api/src/models/tool/.pi-map.index.md
## role
Database models for containerized tool lifecycle management, covering tool definitions, deployment instances, and categorization types.
Provides SQLAlchemy ORM models for managing containerized tool definitions, types, and deployed instances in the API.
## files
- __init__.py | Exports the public API for the tool models module by re-exporting three key classes. | dep: src.models.tool.tool_definition_manifest, src.models.tool.tool_instance, src.models.tool.tool_type
- tool_definition_manifest.py | Defines a SQLAlchemy ORM model for storing tool definition manifests that compile to Dockerfiles and Compose files, supporting both base definitions and tool-specific definitions with inheritance. | exp: class:ToolDefinitionManifest | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.user
- tool_instance.py | Defines a SQLAlchemy ORM model for tool instances that represent deployed tools with container metadata, status tracking, and relationships to users, projects, workspaces, and other entities. | exp: class:ToolInstance | dep: uuid, datetime, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models, src.models.project, src.models.user, src.models (ConfigProfile, GitRepository, Project, ToolType, User, Workspace)
- tool_type.py | Defines a SQLAlchemy ORM model for tool types that represent configurable categories of tools with deployment templates, manifest references, and metadata. | exp: class:ToolType | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.tool.tool_definition_manifest, src.models.user
- tool_type.py | Defines a SQLAlchemy ORM model for tool types that specify configuration templates and metadata for deployable tools in a containerized environment. | exp: class:ToolType | dep: uuid, typing, sqlalchemy, sqlalchemy.orm, src.models.base, src.models.tool.tool_definition_manifest, src.models.user
## arch
SQLAlchemy ORM with declarative models using inheritance hierarchies, relationship mappings, and polymorphic manifest compilation for Docker/Compose deployment.
Domain-driven data models using SQLAlchemy ORM with declarative base pattern, entity relationships, and inheritance support for tool manifest definitions.
## tags
tool, models, src, sqlalchemy, orm, definition, manifest, base
tool, models, src, sqlalchemy, orm, definition, base, manifest
## symbols
- ToolDefinitionManifest
- ToolInstance
+3
View File
@@ -39,6 +39,9 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
)
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
home_directory: Mapped[str] = mapped_column(
String(255), nullable=False, default="/home/user"
)
required_variables: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/seeds
## role
Provides database seeding utilities for initializing built-in tool type configurations in the API application.
Provides database seeding utilities for initializing and synchronizing built-in data records in the API application.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+3 -3
View File
@@ -4,12 +4,12 @@ dir: apps/api/src/seeds
index: apps/api/src/seeds/.pi-map.index.md
## role
Provides database seeding utilities for initializing built-in tool type configurations in the API application.
Provides database seeding utilities for initializing and synchronizing built-in data records in the API application.
## files
- __init__.py | Marks the directory as a Python package for database seeding utilities.
- builtin_tool_types.py | Seeds built-in tool types (code-server, jupyter-notebook, opencode) into a database with upsert logic, creating or updating Docker Compose-based development environment templates. | exp: func:_table_exists(session, table_name: str) → bool, call:session.execute, call:text, call:result.scalar, func:seed_builtin_tool_types(), call:SessionLocal, call:_table_exists, call:logger.warning, call:session.scalar, call:select(ToolType).where, call:ToolType, call:tool_data.get, call:session.add, call:logger.info, call:session.commit | dep: logging, sqlalchemy, src.database, src.models
- builtin_tool_types.py | Seeds predefined built-in tool types (code-server, jupyter-notebook, opencode) into a database with upsert logic, creating them if missing or updating existing ones to match code changes. | exp: func:_table_exists(session, table_name: str) → bool, call:session.execute, call:text, call:result.scalar, func:seed_builtin_tool_types(), call:SessionLocal, call:_table_exists, call:logger.warning, call:session.scalar, call:select(ToolType).where, call:ToolType, call:tool_data.get, call:session.add, call:logger.info, call:session.commit | dep: logging, sqlalchemy, src.database, src.models
## arch
Simple procedural seeding script using SQLAlchemy upsert operations to populate reference data for containerized development environment templates.
Simple imperative seeding scripts with upsert pattern for idempotent data initialization, using direct database operations without abstraction layers.
## tags
tool, types, table, exists, builtin, call:tool, database, init
## symbols
+3 -3
View File
@@ -65,7 +65,7 @@ services:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
restart: 'no'""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
@@ -87,7 +87,7 @@ services:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
@@ -122,7 +122,7 @@ services:
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped""",
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/services
## role
Marks the services directory as a Python package for business logic layer components.
Service layer package for the API application, intended to contain business logic implementations.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/src/services
index: apps/api/src/services/.pi-map.index.md
## role
Provides a Python package namespace for organizing service-layer modules in the API application.
Service layer package for the API application, intended to contain business logic implementations.
## files
- __init__.py | Empty file with no functionality
## arch
Standard Python package structure using __init__.py for directory-based module organization, following conventional layered architecture patterns.
Standard Python package structure with an empty initializer, awaiting service module implementations following a layered architecture pattern.
## tags
init, empty, functionality
## symbols
+213 -31
View File
@@ -8,6 +8,7 @@ from typing import Any
import yaml
from src.services.config.config_profile_resolver import expand_container_path
from src.services.docker import sort_volumes_by_specificity
@@ -142,13 +143,6 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(" rm -rf /var/lib/apt/lists/*")
lines.append("")
# NPM global packages
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
lines.append(f"RUN npm install -g {pkg_list}")
lines.append("")
# Pip packages
pip_packages = manifest.get("packages", {}).get("pip", [])
if pip_packages:
@@ -158,6 +152,9 @@ def compile_dockerfile(manifest: dict) -> str:
# User creation
user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
npm_prefix = ""
if user:
name = user["name"]
uid = user["uid"]
@@ -168,22 +165,21 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
lines.append("")
# Set HOME and USER for runtime compatibility
home = f"/home/{name}"
lines.append(f"ENV HOME={home}")
lines.append(f"ENV HOME={home_dir}")
lines.append(f"ENV USER={name}")
lines.append("")
# Ensure home directory exists and is writable by the user.
# Recursively chown so any files copied from /etc/skel by useradd -m
# (e.g. .bashrc, .config) are owned by the container user.
lines.append(
f"RUN mkdir -p {home} && chown -R {name}:{name} {home} && chmod 755 {home}"
f"RUN mkdir -p {home_dir} && chown -R {name}:{name} {home_dir} && chmod 755 {home_dir}"
)
# Pre-create common config directories so apps like ranger can write
# their configs on first run without permission errors.
common_dirs = [".config", ".local/share", ".cache"]
for d in common_dirs:
lines.append(
f"RUN mkdir -p {home}/{d} && chown -R {name}:{name} {home}/{d}"
f"RUN mkdir -p {home_dir}/{d} && chown -R {name}:{name} {home_dir}/{d}"
)
lines.append("")
@@ -193,6 +189,20 @@ def compile_dockerfile(manifest: dict) -> str:
)
lines.append("")
# NPM global packages: install into a user-writable prefix so the
# container user can update global packages without touching
# /usr/lib/node_modules (which is owned by root).
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
npm_prefix = f"{home_dir}/.npm-global"
lines.append(
f"RUN mkdir -p {npm_prefix} && "
f"npm install -g --prefix {npm_prefix} {pkg_list}"
)
lines.append(f"ENV PATH={npm_prefix}/bin:$PATH")
lines.append("")
# Build scripts
build_scripts = manifest.get("scripts", {}).get("build", [])
for script in build_scripts:
@@ -208,19 +218,38 @@ def compile_dockerfile(manifest: dict) -> str:
# After build scripts, ensure everything in home is owned by the user
if user and build_scripts:
lines.append(f"RUN chown -R {name}:{name} {home}")
lines.append(f"RUN chown -R {name}:{name} {home_dir}")
lines.append("")
# Create mount target directories
# Create mount target directories that do NOT depend on runtime variables.
# Targets containing {{WORKSPACE_NAME}} will be created at container
# startup by the entrypoint, once the actual workspace/repo name is known.
mounts = manifest.get("mounts", [])
if mounts:
dirs = [mount["target"] for mount in mounts]
dir_str = " ".join(dirs)
static_dirs = [
mount["target"] for mount in mounts
if "{{WORKSPACE_NAME}}" not in mount.get("target", "")
]
if static_dirs:
dir_str = " ".join(static_dirs)
lines.append(f"RUN mkdir -p {dir_str}")
if user:
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
lines.append("")
# Create the /workspace compatibility symlink only when the workspace name
# is known at image-build time. Otherwise the entrypoint creates it at
# runtime from the WORKSPACE_NAME environment variable.
workspace_target = f"{home_dir}/{workspace_name}"
if "{{WORKSPACE_NAME}}" not in workspace_name:
lines.append(f"RUN mkdir -p {workspace_target}")
if user:
lines.append(
f"RUN ln -sfn {workspace_target} /workspace && chown -R {user['name']}:{user['name']} {home_dir}"
)
else:
lines.append(f"RUN ln -sfn {workspace_target} /workspace")
lines.append("")
# Entrypoint for startup scripts
startup_scripts = manifest.get("scripts", {}).get("startup", [])
if startup_scripts:
@@ -230,14 +259,22 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
lines.append("")
# Switch to runtime user
if user:
lines.append(f"USER {user['name']}")
lines.append(f"WORKDIR /home/{user['name']}")
lines.append("")
# Do not switch to the runtime user in the Dockerfile. The entrypoint
# starts as root so it can create the /workspace compatibility symlink
# (which lives under /) and fix mount ownership, then it drops privileges
# to the container user before exec-ing the real command.
# Set WORKDIR to the configured home directory unless runtime.working_dir
# explicitly overrides it.
runtime = manifest.get("runtime", {})
working_dir = runtime.get("working_dir")
if working_dir:
lines.append(f"WORKDIR {expand_container_path(working_dir, home_dir)}")
else:
lines.append(f"WORKDIR {home_dir}")
lines.append("")
# Entrypoint and CMD
runtime = manifest.get("runtime", {})
if startup_scripts:
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
@@ -251,6 +288,11 @@ def compile_dockerfile(manifest: dict) -> str:
def compile_entrypoint(manifest: dict) -> str:
"""Generate the startup entrypoint script from startup scripts.
Injects a permission-fixer preamble that runs as root (or via sudo) before
any user-defined startup script. It chowns the home directory and a safe
subset of mount parents to the container user, creates the /workspace
compatibility symlink, and avoids recursive chown of large repo subtrees.
Args:
manifest: Fully resolved manifest JSON.
@@ -259,12 +301,108 @@ def compile_entrypoint(manifest: dict) -> str:
"""
lines = ["#!/bin/bash", "set -e", ""]
user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
# Permission fixer preamble: run as root when possible, else fall back to
# passwordless sudo configured in the Dockerfile.
lines.append("# Permission fixer preamble")
lines.append("CONTAINER_USER=''")
lines.append('if [ "$(id -u)" = '"'"'0'"'"' ]; then')
if user:
lines.append(f" CONTAINER_USER='{user['name']}'")
lines.append("else")
lines.append(" # Try passwordless sudo; ignore failure so the container still starts")
lines.append(" if sudo -n true 2>/dev/null; then")
lines.append(" SUDO='sudo'")
lines.append(" else")
lines.append(" SUDO=''")
lines.append(" fi")
lines.append("fi")
lines.append("")
if user:
name = user["name"]
uid = user["uid"]
gid = user["gid"]
lines.append(f"USER_NAME='{name}'")
lines.append(f"USER_UID='{uid}'")
lines.append(f"USER_GID='{gid}'")
lines.append(f"HOME_DIR='{home_dir}'")
lines.append('WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"')
lines.append('WORKSPACE_TARGET="${HOME_DIR}/${WORKSPACE_NAME}"')
lines.append("")
lines.append("fix_owner() {")
lines.append(" local path=\"$1\"")
lines.append(' [ -e "$path" ] || return 0')
lines.append(' if [ -n "$SUDO" ]; then')
lines.append(' sudo chown "$USER_UID:$USER_GID" "$path" 2>/dev/null || true')
lines.append(' elif [ "$(id -u)" = "0" ]; then')
lines.append(' chown "$USER_UID:$USER_GID" "$path" 2>/dev/null || true')
lines.append(' fi')
lines.append("}")
lines.append("")
lines.append("# Ensure home directory exists and is owned by the container user")
lines.append('mkdir -p "$HOME_DIR"')
lines.append('fix_owner "$HOME_DIR"')
lines.append("")
lines.append("# Ensure workspace target exists and is owned by the container user")
lines.append('mkdir -p "$WORKSPACE_TARGET"')
lines.append('fix_owner "$WORKSPACE_TARGET"')
lines.append("")
lines.append("# Remove stale placeholder directory baked into older images")
lines.append('if [ -d "${HOME_DIR}/{{WORKSPACE_NAME}}" ]; then')
lines.append(' rm -rf "${HOME_DIR}/{{WORKSPACE_NAME}}"')
lines.append('fi')
lines.append("")
lines.append("# Create /workspace compatibility symlink")
lines.append("# / is owned by root, so we need root or passwordless sudo.")
lines.append('if [ "$(id -u)" = "0" ]; then')
lines.append(' ln -sfn "$WORKSPACE_TARGET" /workspace')
lines.append('elif [ -n "$SUDO" ]; then')
lines.append(' sudo ln -sfn "$WORKSPACE_TARGET" /workspace')
lines.append('else')
lines.append(' ln -sfn "$WORKSPACE_TARGET" /workspace 2>/dev/null || true')
lines.append('fi')
lines.append("")
lines.append("# Fix ownership of declared mount targets (top-level only)")
for mount in manifest.get("mounts", []):
target = mount.get("target")
if not target:
continue
# Expand any ~/$HOME placeholders and the runtime workspace name
# in the mount target so ownership is fixed at container startup.
expanded = (
target.replace("~", home_dir)
.replace("$HOME", home_dir)
.replace("{{WORKSPACE_NAME}}", "${WORKSPACE_NAME}")
)
if expanded.startswith(home_dir) and not mount.get("readonly", False):
lines.append(f'fix_owner "{expanded}"')
lines.append("")
startup_scripts = manifest.get("scripts", {}).get("startup", [])
for script in startup_scripts:
lines.append(script)
lines.append("")
lines.append('exec "$@"')
# Drop from root to the container user before running the real command.
# The Dockerfile no longer sets USER, so the entrypoint has root for the
# setup above. `runuser` (root-only, no PAM) preserves the environment,
# stdin, and TTY so interactive tools like bash keep running.
if user:
name = user["name"]
lines.append("# Drop privileges to the container user")
# When the container command is a shell, force an interactive login
# shell. Detached containers may not have stdin connected, and a plain
# /bin/bash invocation exits immediately with code 0. -il keeps it
# alive so the container stays running for docker exec/web terminals.
lines.append('if [ "$1" = "/bin/bash" ] || [ "$1" = "bash" ]; then')
lines.append(f' exec runuser -u {name} -- /bin/bash -il')
lines.append('fi')
lines.append(f'exec runuser -u {name} -- "$@"')
else:
lines.append('exec "$@"')
return "\n".join(lines)
@@ -281,11 +419,18 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
runtime = manifest.get("runtime", {})
user = manifest.get("user")
interface_type = manifest["interface_type"]
home_dir = get_manifest_home_dir(manifest)
# Determine the workspace/repo name from variables when available.
workspace_name = variables.get(
"WORKSPACE_NAME",
variables.get("REPO_NAME", "workspace"),
)
service: dict[str, Any] = {
"image": variables["IMAGE_TAG"],
"container_name": variables["INSTANCE_NAME"],
"restart": "unless-stopped",
"restart": "no",
}
# Terminal-specific fields
@@ -294,11 +439,16 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if runtime.get("tty", False):
service["tty"] = True
if runtime.get("working_dir"):
service["working_dir"] = runtime["working_dir"]
service["working_dir"] = expand_container_path(
runtime["working_dir"], home_dir
)
# User override
# The entrypoint starts as root (Dockerfile does not set USER) so it can
# create the /workspace compatibility symlink and fix mount ownership. It
# drops privileges to the container user internally before exec-ing the
# real command, so do not set compose-level user override here.
if user:
service["user"] = f"{user['uid']}:{user['gid']}"
service["user"] = "0:0"
# Ports for web tools
default_port = manifest.get("default_port")
@@ -310,6 +460,12 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if env:
service["environment"] = dict(env)
# Expose the workspace/repo name so the entrypoint can finalize the
# /workspace compatibility symlink at container startup.
if "environment" not in service:
service["environment"] = {}
service["environment"]["WORKSPACE_NAME"] = workspace_name
# Merge extra env from config
extra_env = variables.get("EXTRA_ENV", {})
if extra_env:
@@ -319,14 +475,25 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
# Volumes from mount schema
volumes = []
has_explicit_repo_mount = False
for mount in manifest.get("mounts", []):
source = resolve_mount_source(mount, variables)
if not source:
continue
target = mount["target"]
if mount.get("source_type") == "repo":
has_explicit_repo_mount = True
target = expand_container_path(mount["target"], home_dir)
target = target.replace("{{WORKSPACE_NAME}}", workspace_name)
readonly = ":ro" if mount.get("readonly", False) else ""
volumes.append(f"{source}:{target}{readonly}")
# Synthesize a default repo/workspace mount when the manifest does not
# declare an explicit repo mount. This preserves the repo root directory
# name under the configured home directory.
if not has_explicit_repo_mount and variables.get("REPO_PATH"):
target = f"{home_dir}/{workspace_name}"
volumes.append(f"{variables['REPO_PATH']}:{target}")
# Append extra volumes from tool config / config profile
for vol in variables.get("EXTRA_VOLUMES", []):
vol_str = f"{vol['source']}:{vol['target']}"
@@ -385,7 +552,12 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
def get_manifest_home_dir(manifest: dict) -> str:
"""Get the home directory for a container based on manifest user config.
"""Get the home directory for a container based on manifest config.
Precedence:
1. manifest["home_directory"] if present and non-empty.
2. /home/{user.name} if manifest.user.name is present.
3. /root otherwise.
Args:
manifest: Fully resolved manifest JSON.
@@ -393,6 +565,10 @@ def get_manifest_home_dir(manifest: dict) -> str:
Returns:
Home directory path (e.g., /home/user or /root).
"""
home_directory = manifest.get("home_directory")
if home_directory and isinstance(home_directory, str) and home_directory.strip():
return home_directory.strip()
user = manifest.get("user")
if user and user.get("name"):
return f"/home/{user['name']}"
@@ -402,6 +578,10 @@ def get_manifest_home_dir(manifest: dict) -> str:
def compute_image_tag(tool_name: str, manifest: dict) -> str:
"""Compute a deterministic image tag from manifest content.
The hash includes the manifest JSON plus a compiler version token so
that changes to the Dockerfile/entrypoint generation logic invalidate
previously built images.
Args:
tool_name: Human-readable tool name.
manifest: Fully resolved manifest JSON.
@@ -409,9 +589,11 @@ def compute_image_tag(tool_name: str, manifest: dict) -> str:
Returns:
Docker image tag string.
"""
# Canonicalize: sort keys, stable JSON
compiler_version = "v4" # bump when compile_dockerfile/entrypoint/compose change
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
hash_suffix = hashlib.sha256(
f"{compiler_version}:{canonical}".encode()
).hexdigest()[:8]
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
return f"headquarter/{safe_name}-{hash_suffix}:latest"
@@ -172,6 +172,47 @@ def _merge_mounts(
return result
def _find_mount_conflicts(
profile: ConfigProfile,
resolved: ResolvedProfile,
) -> list[dict[str, Any]]:
"""Find mounts on the profile that override mounts from included profiles.
Returns a list of conflict descriptors with the target path, the included
profile that originally provided the mount, and the current profile name.
"""
conflicts = []
own_targets = {m["target"] for m in (profile.mounts or [])}
own_files = {f for m in (profile.mounts or []) for f in m.get("files", {}).keys()}
for mount in resolved.mounts.values():
if mount.target in own_targets:
# The profile itself has a mount at the same target as an included one.
conflicts.append(
{
"type": "mount_target",
"target": mount.target,
"overridden_by": profile.name,
"source": resolved.profile_name,
}
)
continue
for rel_path in mount.files:
if rel_path in own_files:
conflicts.append(
{
"type": "mount_file",
"target": mount.target,
"file": rel_path,
"overridden_by": profile.name,
"source": resolved.profile_name,
}
)
return conflicts
def _merge_git_mounts(
base: list[dict[str, Any]],
overlay: list[dict[str, Any]],
@@ -2,7 +2,7 @@
dir: apps/api/src/services/docker
## role
Provides Docker infrastructure services for container lifecycle management, compose orchestration, configuration deployment, and secure tunneling to expose internal services.
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
+4 -4
View File
@@ -4,15 +4,15 @@ dir: apps/api/src/services/docker
index: apps/api/src/services/docker/.pi-map.index.md
## role
Provides Docker infrastructure services for container lifecycle management, compose orchestration, configuration deployment, and secure tunneling to expose internal services.
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling.
## files
- __init__.py | Package initialization file that exposes Docker-related service functions for container operations, compose management, configuration staging, and tunnel management. | dep: src.services.docker.compose, src.services.docker.config_staging, src.services.docker.container, src.services.docker.tunnel
- compose.py | Generates, renders, and executes Docker Compose commands for container orchestration with volume sorting and template substitution. | exp: func:sort_volumes_by_specificity(volumes: list[str]) → list[str], call:vol.split, call:len, call:parts[1].rstrip, call:target.count, call:targets.append, call:Counter(targets).items, call:logger.warning, call:sorted, func:_target_depth(vol: str) → int, call:vol.split, call:len, call:parts[1].rstrip, call:target.count, func:render_compose_template(template: str, variables: dict[str, Any]) → str, call:variables.items, call:result.replace, call:str, func:write_compose_file(instance_dir: str, content: str) → str, call:Path, call:compose_path.write_text, call:str, func:execute_compose_command(compose_path: str, action: str, timeout, env_file) → tuple[int, str, str], call:Path, call:cmd.extend, call:cmd.append, call:subprocess.run, call:str, raise:ValueError | dep: logging, subprocess, collections, pathlib, typing, collections.Counter, pathlib.Path, typing.Any
- compose.py | Generates, renders, and executes Docker Compose files with volume sorting and template variable substitution. | exp: func:sort_volumes_by_specificity(volumes: list[str]) → list[str], call:vol.split, call:len, call:parts[1].rstrip, call:target.count, call:targets.append, call:Counter(targets).items, call:logger.warning, call:sorted, func:_target_depth(vol: str) → int, call:vol.split, call:len, call:parts[1].rstrip, call:target.count, func:render_compose_template(template: str, variables: dict[str, Any]) → str, call:variables.items, call:result.replace, call:str, call:aliases.items, call:variables.get, func:write_compose_file(instance_dir: str, content: str) → str, call:Path, call:compose_path.write_text, call:str, func:execute_compose_command(compose_path: str, action: str, timeout, env_file) → tuple[int, str, str], call:Path, call:cmd.extend, call:cmd.append, call:subprocess.run, call:str, raise:ValueError | dep: logging, subprocess, collections, pathlib, typing, collections.Counter, pathlib.Path, typing.Any
- config_staging.py | Stages configuration files into instance directories with security checks for path traversal. | exp: func:ensure_instance_directory(instance_id: str, base_path) → str, call:Settings, call:Path, call:instance_dir.mkdir, call:str, call:instance_dir.absolute, func:write_env_file(instance_dir: str, env_vars: dict[str, str]) → str, call:Path, call:env_vars.items, call:env_path.write_text, call:"\n".join, call:str, func:write_config_files(instance_dir: str, files: dict[str, str]) → None, call:Path, call:files.items, call:full_path.resolve().relative_to, call:instance_path.resolve, call:full_path.parent.mkdir, call:full_path.write_text, raise:ValueError | dep: logging, pathlib, src.config, src.config.Settings
- container.py | Provides Docker container runtime queries and network management utilities via subprocess calls to the Docker CLI. | exp: func:get_container_id(instance_name: str) → str | None, call:instance_name.lower, call:subprocess.run, call:result.stdout.strip, call:ps_result.stdout.strip().splitlines, call:line.split, call:len, call:name.lower, func:get_container_name(instance_name: str) → str | None, call:subprocess.run, call:instance_name.lower, call:result.stdout.strip().lstrip, func:get_backend_network_name() → str, call:subprocess.run, call:result.stdout.strip().split, call:net.lower, func:connect_container_to_network(container_name: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_ip_on_network(container_id: str, network_name) → str | None, call:get_backend_network_name, call:subprocess.run, call:result.stdout.strip, func:is_container_on_network(container_id: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_status(container_id: str) → dict[str, Any], call:subprocess.run, call:result.stdout.strip().split, call:int, call:len, call:parts[1].isdigit, func:wait_for_container_running(container_id: str, timeout, interval) → dict[str, Any], call:time.time, call:get_container_status, call:time.sleep, func:get_container_logs(container_id: str, tail) → str, call:subprocess.run, call:str, func:find_free_port(start, end) → int, call:range, call:socket.socket, call:s.connect_ex, raise:RuntimeError | dep: logging, subprocess, time, typing, socket
- container.py | Provides utility functions for querying Docker container runtime state, managing container network connections, and finding free TCP ports via subprocess calls to the Docker CLI. | exp: func:get_container_id(instance_name: str) → str | None, call:instance_name.lower, call:subprocess.run, call:result.stdout.strip, call:ps_result.stdout.strip().splitlines, call:line.split, call:len, call:name.lower, func:get_container_name(instance_name: str) → str | None, call:subprocess.run, call:instance_name.lower, call:result.stdout.strip().lstrip, func:get_backend_network_name() → str, call:subprocess.run, call:result.stdout.strip().split, call:net.lower, func:connect_container_to_network(container_name: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_ip_on_network(container_id: str, network_name) → str | None, call:get_backend_network_name, call:subprocess.run, call:result.stdout.strip, func:is_container_on_network(container_id: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_status(container_id: str) → dict[str, Any], call:subprocess.run, call:result.stdout.strip().split, call:int, call:len, call:parts[1].isdigit, func:wait_for_container_running(container_id: str, timeout, interval) → dict[str, Any], call:time.time, call:get_container_status, call:time.sleep, func:get_container_logs(container_id: str, tail) → str, call:subprocess.run, call:str, func:find_free_port(start, end) → int, call:range, call:socket.socket, call:s.connect_ex, raise:RuntimeError | dep: logging, subprocess, time, typing, socket
- tunnel.py | Manages Cloudflare tunnels by orchestrating cloudflared Docker containers to expose internal services via temporary public URLs. | exp: func:_tunnel_container_name(instance_name: str) → str, call:instance_name.lower, func:_ensure_image() → None, call:subprocess.run, call:result.stdout.strip, call:logger.info, call:logger.warning, func:_cleanup_stale_tunnel(tunnel_name: str) → None, call:subprocess.run, func:_get_tunnel_logs(tunnel_name: str) → tuple[str, str], call:subprocess.run, func:_get_tunnel_exit_code(tunnel_name: str) → int | None, call:subprocess.run, call:int, call:result.stdout.strip, func:start_tunnel(instance_name: str, container_port: int, timeout, target_url) → dict[str, str], call:_ensure_image, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:instance_name.lower, call:get_backend_network_name, call:logger.debug, call:" ".join, call:subprocess.run, call:proc.stdout.strip, call:re.compile, call:__import__("time").time, call:_get_tunnel_logs, call:url_pattern.search, call:match.group, call:_get_tunnel_exit_code, call:__import__("time").sleep, call:logger.info, raise:RuntimeError, func:stop_tunnel(instance_name: str) → None, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:logger.debug, func:recreate_tunnel(instance_name: str, container_port: int, target_url) → dict[str, str], call:stop_tunnel, call:start_tunnel, func:check_tunnel_health(url: str, timeout) → dict[str, Any], call:subprocess.run, call:int, call:result.stdout.strip, call:str(exc).lower, call:any | dep: logging, re, subprocess, typing, src.services.docker.container
## arch
Subprocess-based CLI wrapper architecture around Docker/cloudflared tools with template rendering, path-traversal-safe file staging, and functional decomposition into single-responsibility modules.
Subprocess-based CLI wrapper architecture with template-driven file generation, security-validated file staging, and container-orchestrated tunnel proxying.
## tags
tunnel, container, call:subprocess.run, get, name, call:, network, call:result.stdout.strip
## symbols
+13
View File
@@ -61,6 +61,19 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
# Convenience aliases so legacy and migrated templates can use lowercase
# placeholders without changing every stored template.
aliases = {
"{{workspace_name}}": "WORKSPACE_NAME",
"{{home_directory}}": "HOME_DIRECTORY",
}
for alias_placeholder, key in aliases.items():
if alias_placeholder in result:
result = result.replace(
alias_placeholder, str(variables.get(key, "workspace"))
)
return result
+10 -4
View File
@@ -283,7 +283,7 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
tail: Number of lines to return
Returns:
Container logs
Container logs (stdout + stderr)
"""
result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id],
@@ -291,9 +291,15 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
text=True,
)
if result.returncode == 0:
return result.stdout
return f"Failed to get logs: {result.stderr}"
if result.returncode != 0:
return f"Failed to get logs: {result.stderr}"
logs = result.stdout
if result.stderr:
if logs:
logs += "\n"
logs += f"STDERR:\n{result.stderr}"
return logs
def find_free_port(start: int = 10000, end: int = 20000) -> int:
@@ -2,7 +2,7 @@
dir: apps/api/src/services/instance
## role
Provides infrastructure for managing tool instance lifecycle events, health monitoring, and asynchronous communication within the API service.
Coordinates tool instance lifecycle events, health monitoring, and notifications across the API service.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
+3 -3
View File
@@ -4,14 +4,14 @@ dir: apps/api/src/services/instance
index: apps/api/src/services/instance/.pi-map.index.md
## role
Provides infrastructure for managing tool instance lifecycle events, health monitoring, and asynchronous communication within the API service.
Coordinates tool instance lifecycle events, health monitoring, and notifications across the API service.
## files
- __init__.py | Exports public API for instance lifecycle services module | dep: src.services.instance.event_bus, src.services.instance.health_monitor, src.services.instance.lifecycle_hooks
- event_bus.py | Implements a singleton in-memory typed event bus with publish/subscribe pattern for instance lifecycle and health events, supporting both sync and async callbacks with exception isolation. | exp: class:InstanceEventBus, method:__init__(self) → None, method:__new__(cls) → "InstanceEventBus", call:super().__new__, method:_reset_for_testing(self) → None, call:self._subscribers.clear, method:subscribe(self, event_type: str, callback: EventCallback) → Callable[[], None], call:str, call:uuid.uuid4, call:self._subscribers[event_type].append, call:self.unsubscribe, method:unsubscribe(self, event_type: str, callback_id: str) → None, method:unsubscribe_all(self, event_type: str) → None, call:self._subscribers.pop, method:publish(self, event_type: str, payload: InstanceEventPayload) → None, call:callbacks.extend, call:self._subscribers.get, call:inspect.iscoroutinefunction, call:callback, call:payload.get, call:logger.exception | dep: asyncio, inspect, logging, uuid, collections.abc, typing
- health_monitor.py | Background health monitor that periodically polls Docker container and tunnel health for tool instances, publishing state change events and notifications. | exp: class:HealthSnapshot, class:HealthMonitor, method:__init__(self, event_bus: InstanceEventBus) → None, method:start(self) → None, call:self._task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._poll_loop, method:stop(self) → None, call:self._task.done, call:self._task.cancel, call:self._last_known_state.clear, method:_poll_loop(self) → None, call:asyncio.sleep, call:self._run_check_cycle, call:logger.exception, method:_run_check_cycle(self) → None, call:SessionLocal, call:session.execute, call:select(ToolInstance).where, call:ToolInstance.status.in_, call:result.scalars().all, call:self._check_instance, method:_check_instance(self, session: AsyncSession, instance: ToolInstance) → None, call:logger.debug, call:get_container_status, call:logger.exception, call:str, call:get_correlation_id, call:check_tunnel_health, call:tunnel_result.get, call:HealthSnapshot, call:self._last_known_state.get, call:self._derive_status, call:self._snapshots_equal, call:self._handle_state_change, method:_derive_status(self, snapshot: HealthSnapshot, previous: HealthSnapshot | None, current_status: str | None) → str, method:_snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) → bool, method:_handle_state_change(self, session: AsyncSession, instance: ToolInstance, previous: HealthSnapshot | None, snapshot: HealthSnapshot, new_status: str) → None, call:HealthCheck, call:session.add, call:session.commit, call:get_correlation_id, call:str, call:datetime.now(timezone.utc).isoformat, call:self._event_bus.publish, call:notification_service.create_notification, call:logger.exception | dep: asyncio, logging, uuid, dataclasses, datetime, sqlalchemy, sqlalchemy.ext.asyncio, src.database, src.models, src.services.shared.correlation, src.services.docker, src.services.shared.tunnel, src.services.instance.event_bus, src.services.shared.notification_service
- health_monitor.py | Background health monitor that polls Docker container and tunnel health for tool instances, publishes state change events, and creates notifications for errors/unhealthy states. | exp: class:HealthSnapshot, class:HealthMonitor, method:__init__(self, event_bus: InstanceEventBus) → None, method:start(self) → None, call:self._task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._poll_loop, method:stop(self) → None, call:self._task.done, call:self._task.cancel, call:self._last_known_state.clear, method:_poll_loop(self) → None, call:asyncio.sleep, call:self._run_check_cycle, call:logger.exception, method:_run_check_cycle(self) → None, call:SessionLocal, call:session.execute, call:select(ToolInstance).where, call:ToolInstance.status.in_, call:result.scalars().all, call:self._check_instance, method:_check_instance(self, session: AsyncSession, instance: ToolInstance) → None, call:logger.debug, call:get_container_status, call:logger.exception, call:str, call:get_correlation_id, call:check_tunnel_health, call:tunnel_result.get, call:HealthSnapshot, call:self._last_known_state.get, call:self._derive_status, call:self._snapshots_equal, call:self._handle_state_change, method:_derive_status(self, snapshot: HealthSnapshot, previous: HealthSnapshot | None, current_status: str | None) → str, method:_snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) → bool, method:_handle_state_change(self, session: AsyncSession, instance: ToolInstance, previous: HealthSnapshot | None, snapshot: HealthSnapshot, new_status: str) → None, call:HealthCheck, call:session.add, call:session.commit, call:get_correlation_id, call:str, call:datetime.now(timezone.utc).isoformat, call:self._event_bus.publish, call:notification_service.create_notification, call:logger.exception | dep: asyncio, logging, uuid, dataclasses, datetime, sqlalchemy, sqlalchemy.ext.asyncio, src.database, src.models, src.services.shared.correlation, src.services.docker, src.services.shared.tunnel, src.services.instance.event_bus, src.services.shared.notification_service
- lifecycle_hooks.py | Provides helpers to publish tool instance lifecycle events, persist audit records, and conditionally send user notifications. | exp: func:_derive_title(event_type: str) → str, call:mapping.get, call:event_type.replace("instance.", "").replace("_", " ").title, func:_should_notify(event_type: str, status: str | None) → bool, func:_build_payload(event_type: str, instance: ToolInstance, status, message, metadata) → InstanceEventPayload, call:str, call:datetime.now(timezone.utc).isoformat, call:get_correlation_id, func:_write_audit_row(session: AsyncSession, instance: ToolInstance, event_type: str, created_by, status, message, metadata) → InstanceEvent, call:InstanceEvent, call:event_type.replace, call:session.add, call:session.commit, func:publish_lifecycle_event(event_bus: InstanceEventBus, session: AsyncSession, instance: ToolInstance, event_type: str, created_by, status, message, metadata) → None, call:_build_payload, call:_write_audit_row, call:event_bus.publish, call:_should_notify, call:_derive_title, call:notification_service.create_notification, call:logger.exception, call:payload.get | dep: logging, uuid, datetime, sqlalchemy.ext.asyncio, src.models, src.services.shared.correlation, src.services.instance.event_bus, src.services.shared.notification_service
## arch
Event-driven architecture using a singleton in-memory pub/sub event bus with typed messages, background polling workers, and lifecycle hooks that bridge domain events to persistence and notifications with exception isolation between sync/async handlers.
Observer pattern via typed singleton event bus with async/sync subscribers, background polling loops, and side-effect hooks for persistence and notifications.
## tags
call:self., instance, src, services, event, health, call:logger.exception, check
## symbols
@@ -203,6 +203,11 @@ class HealthMonitor:
# Transient states (created, restarting) — preserve current status
# instead of treating them as an error. The next poll will resolve.
if snapshot.container_status in ("created", "restarting"):
# A container that was already running and is now restarting has
# crashed (e.g. entrypoint failure / restart loop). Mark it failed
# so the dashboard does not keep showing it as running.
if current_status == "running":
return "error"
return current_status or "starting"
# Unknown/unexpected state (paused, etc.)
@@ -287,9 +292,8 @@ class HealthMonitor:
return
# Skip "not_found" errors for containers that were never running
# (e.g. still starting, or intentionally stopped/deleted).
if (
snapshot.container_status == "not_found"
and (previous is None or previous.container_status != "running")
if snapshot.container_status == "not_found" and (
previous is None or previous.container_status != "running"
):
return
category = "instance"
@@ -2,12 +2,14 @@
dir: apps/api/src/services/shared
## role
Provides reusable, cross-cutting infrastructure services for security, I/O, container operations, and workspace management used throughout the API layer.
Provides common, cross-cutting backend services used by multiple API components for security, infrastructure, and user-facing operations.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
## children
-
- apps/api/src/services/shared/.ruff_cache
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
map: apps/api/src/services/shared/.ruff_cache/.pi-map.md
## files
- __init__.py
- correlation.py
@@ -24,5 +26,7 @@ map: apps/api/src/services/shared/.pi-map.md
## workflows
- change shared behavior
read: __init__.py, correlation.py, file_service.py
- explore shared subdirectories
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
## dirty
-
+6 -4
View File
@@ -4,7 +4,7 @@ dir: apps/api/src/services/shared
index: apps/api/src/services/shared/.pi-map.index.md
## role
Provides reusable, cross-cutting infrastructure services for security, I/O, container operations, and workspace management used throughout the API layer.
Provides common, cross-cutting backend services used by multiple API components for security, infrastructure, and user-facing operations.
## files
- __init__.py | Re-exports shared service classes and functions from a services package to provide a unified public API | dep: src.services.shared.correlation, src.services.shared.file_service, src.services.shared.notification_service, src.services.shared.permission_fixer, src.services.shared.readiness_probe, src.services.shared.ssh_keys, src.services.shared.tunnel, src.services.shared.workspace_manager, correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager
- correlation.py | Provides async correlation ID tracking via context variables and FastAPI middleware for request tracing. | exp: class:CorrelationIdMiddleware, method:dispatch(self, request: Request, call_next), call:request.headers.get, call:str, call:uuid.uuid4, call:CORRELATION_ID.set, call:call_next, call:CORRELATION_ID.reset, func:get_correlation_id() → str, call:CORRELATION_ID.get, call:str, call:uuid.uuid4 | dep: contextvars, uuid, fastapi, starlette.middleware.base, fastapi.Request, starlette.middleware.base.BaseHTTPMiddleware
@@ -14,11 +14,11 @@ Provides reusable, cross-cutting infrastructure services for security, I/O, cont
- readiness_probe.py | Executes a retryable readiness probe command inside a Docker container with configurable timeout and interval | exp: func:execute_probe(container_id: str, command: str, timeout, interval) → tuple[bool, list[str]], call:asyncio.get_event_loop().time, call:logs.append, call:logger.debug, call:subprocess.run, call:result.stdout.strip, call:result.stderr.strip, call:asyncio.sleep | dep: asyncio, logging, subprocess
- ssh_keys.py | Decrypts and writes SSH key files to instance directories for container mounting, with optional ownership configuration and SSH config generation. | exp: func:_get_fernet() → Fernet, call:Settings, call:hashlib.sha256(settings.session_secret.encode()).digest, call:settings.session_secret.encode, call:base64.urlsafe_b64encode, call:Fernet, func:_sanitize_filename(name: str) → str, call:re.sub, call:sanitized.strip, func:prepare_ssh_key_files(instance_dir: str, ssh_key, subdir, uid, gid, key_filename, write_config) → str, call:Path, call:ssh_dir.mkdir, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:private_key_path.write_text, call:os.chmod, call:public_key_path.write_text, call:config_path.write_text, call:os.chown, call:logger.debug, call:logger.warning, call:os.getuid, call:str, func:write_ssh_config(ssh_dir: str, key_filenames: list[str], uid, gid) → None, call:Path, call:ssh_dir_path.mkdir, call:lines.append, call:"\n".join, call:config_path.write_text, call:os.chmod, call:os.chown, func:cleanup_ssh_key_files(instance_dir: str) → None, call:Path, call:ssh_dir.exists, call:ssh_dir.iterdir, call:file_path.unlink, call:ssh_dir.rmdir | dep: logging, os, re, pathlib, cryptography.fernet, src.config, base64, hashlib
- tunnel.py | Re-exports Docker tunnel functions from a nested module for backward compatibility. | dep: src.services.docker.tunnel
- workspace_manager.py | Manages Git workspace lifecycle operations including creation, deletion, sync, and migration of legacy tool instances into workspace-bound repositories. | exp: class:SyncResult, class:WorkspaceHasInstancesError, method:__init__(self, instances: list[dict]) → None, call:super().__init__, call:len, class:WorkspaceManager, method:_workspace_path(self, repo_id: uuid.UUID, name: str) → str, call:os.path.join, call:str, method:create(self, repo: GitRepository, user_id: uuid.UUID, name: str, branch, session) → Workspace, call:self._workspace_path, call:os.path.dirname, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:logger.info, call:os.path.exists, call:logger.warning, call:shutil.rmtree, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.clone, call:self._make_world_writable, call:Workspace, call:datetime.now, raise:ValueError, method:delete(self, workspace: Workspace, force, session) → None, call:self._get_instances, call:self._stop_and_delete_instance, call:os.path.exists, call:shutil.rmtree, call:logger.info, call:session.delete, raise:ValueError, raise:WorkspaceHasInstancesError, method:sync(self, workspace: Workspace, session) → SyncResult, call:logger.info, call:session.get, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.fetch, call:GitService.branch_exists_remotely, call:SyncResult, call:GitService.pull, call:self._make_world_writable, call:datetime.now, method:_make_world_writable(self, path: str) → None, call:contextlib.suppress, call:os.chmod, call:os.walk, call:os.path.join, call:os.stat, method:_get_instances(self, workspace: Workspace, session: AsyncSession) → list[ToolInstance], call:session.execute, call:select(ToolInstance).where, call:list, call:result.scalars().all, method:_stop_and_delete_instance(self, instance: ToolInstance, session: AsyncSession) → None, call:delete_tool_instance, call:logger.info, call:logger.error, method:ensure_instance_workspace(self, instance: ToolInstance, session: AsyncSession) → Workspace, call:session.get, call:self._workspace_name_exists, call:self._migrate_clone_into_workspace, call:self.create, call:session.add, call:session.commit, call:session.refresh, call:logger.info, raise:RuntimeError, method:_workspace_name_exists(self, session: AsyncSession, repo_id: uuid.UUID, name: str) → bool, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, method:_migrate_clone_into_workspace(self, instance: ToolInstance, repo: "GitRepository", session: AsyncSession, name: str) → Workspace, call:os.path.dirname, call:os.path.join, call:os.path.exists, call:self._workspace_path, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:shutil.rmtree, call:shutil.move, call:self._make_world_writable, call:Workspace, call:datetime.now, call:session.add, call:session.flush, raise:RuntimeError | dep: contextlib, logging, os, shutil, stat, uuid, dataclasses, datetime, typing, sqlalchemy, src.models, src.services.git.git_service, src.services.shared.ssh_keys, sqlalchemy.ext.asyncio, src.services.tool.instance_service
- workspace_manager.py | Manages workspace lifecycle operations including creation, deletion, synchronization, and migration of legacy tool instances into workspace-based repositories. | exp: class:SyncResult, class:WorkspaceHasInstancesError, method:__init__(self, instances: list[dict]) → None, call:super().__init__, call:len, class:WorkspaceManager, method:_workspace_path(self, workspace_id: uuid.UUID, repo: "GitRepository") → str, call:os.path.join, call:str, call:self._repo_directory_name, method:create(self, repo: GitRepository, user_id: uuid.UUID, name: str, branch, session) → Workspace, call:self._repo_directory_name, call:uuid.uuid4, call:os.path.join, call:str, call:logger.info, call:os.path.exists, call:logger.warning, call:shutil.rmtree, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.clone, call:logger.error, call:self._make_world_writable, call:Workspace, call:datetime.now, raise:ValueError, method:delete(self, workspace: Workspace, force, session) → None, call:self._get_instances, call:self._stop_and_delete_instance, call:os.path.exists, call:shutil.rmtree, call:logger.info, call:session.delete, raise:ValueError, raise:WorkspaceHasInstancesError, method:sync(self, workspace: Workspace, session) → SyncResult, call:logger.info, call:session.get, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.fetch, call:GitService.branch_exists_remotely, call:SyncResult, call:GitService.pull, call:self._make_world_writable, call:datetime.now, method:_make_world_writable(self, path: str) → None, call:contextlib.suppress, call:os.chmod, call:os.walk, call:os.path.join, call:os.stat, method:_get_instances(self, workspace: Workspace, session: AsyncSession) → list[ToolInstance], call:session.execute, call:select(ToolInstance).where, call:list, call:result.scalars().all, method:_stop_and_delete_instance(self, instance: ToolInstance, session: AsyncSession) → None, call:delete_tool_instance, call:logger.info, call:logger.error, method:ensure_instance_workspace(self, instance: ToolInstance, session: AsyncSession) → Workspace, call:session.get, call:self._workspace_name_exists, call:self._migrate_clone_into_workspace, call:self.create, call:session.add, call:session.commit, call:session.refresh, call:logger.info, raise:RuntimeError, method:_workspace_name_exists(self, session: AsyncSession, repo_id: uuid.UUID, name: str) → bool, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, method:_migrate_clone_into_workspace(self, instance: ToolInstance, repo: "GitRepository", session: AsyncSession, name: str) → Workspace, call:os.path.dirname, call:os.path.join, call:os.path.exists, call:uuid.uuid4, call:str, call:self._repo_directory_name, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:shutil.rmtree, call:shutil.move, call:self._make_world_writable, call:Workspace, call:datetime.now, call:session.add, call:session.flush, raise:RuntimeError | dep: contextlib, logging, os, shutil, stat, uuid, dataclasses, datetime, typing, sqlalchemy, src.models, src.services.git.git_service, src.services.shared.ssh_keys, src.utils.git_url_parser, sqlalchemy.ext.asyncio, src.services.tool.instance_service
## arch
Modular utility services pattern with async singletons, context variables for request tracing, defensive security (path traversal/ownership isolation), Docker exec abstraction, and Git-backed workspace lifecycle management.
Modular utility services following singleton and async patterns, with Docker/container integration, filesystem sandboxing, context-based request tracing, and strict resource ownership isolation.
## tags
error, workspace, call:self., get, key, src, call:session.execute, call:ssh
error, call:self., get, workspace, src, key, call:str, call:session.execute
## symbols
- CorrelationIdMiddleware
- FileEntry
@@ -31,5 +31,7 @@ error, workspace, call:self., get, key, src, call:session.execute, call:ssh
## workflows
- change shared behavior
read: __init__.py, correlation.py, file_service.py
- explore shared subdirectories
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
## dirty
-
@@ -17,6 +17,7 @@ from sqlalchemy import select
from src.models import Workspace
from src.services.git.git_service import GitService
from src.services.shared.ssh_keys import _get_fernet
from src.utils.git_url_parser import extract_base_repo_url
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -47,9 +48,32 @@ class WorkspaceManager:
BASE_PATH = "/data/working-copies"
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
"""Return the filesystem path for a workspace."""
return os.path.join(self.BASE_PATH, str(repo_id), name)
@staticmethod
def _repo_directory_name(repo: "GitRepository") -> str:
"""Return the directory name git would create for a standard clone.
Prefers the name parsed from the remote URL and falls back to the
user-provided repository name when no remote URL is available.
"""
if repo.remote_url:
base_url = extract_base_repo_url(repo.remote_url) or repo.remote_url
name = base_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
if name:
return name
return repo.name
def _workspace_path(self, workspace_id: uuid.UUID, repo: "GitRepository") -> str:
"""Return the filesystem path for a workspace.
Layout: /data/working-copies/{workspace_id}/{repo_name}/
The workspace_id prevents collisions between workspaces, and the
repo_name matches the directory git clone naturally creates.
"""
return os.path.join(
self.BASE_PATH, str(workspace_id), self._repo_directory_name(repo)
)
async def create(
self,
@@ -74,24 +98,32 @@ class WorkspaceManager:
Raises:
RuntimeError: If git clone fails.
"""
path = self._workspace_path(repo.id, name)
parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
with contextlib.suppress(OSError):
os.chmod(parent, 0o777)
logger.info(
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
)
if not repo.remote_url:
raise ValueError("Repository has no remote URL")
repo_dir_name = self._repo_directory_name(repo)
workspace_id = uuid.uuid4()
parent_path = os.path.join(self.BASE_PATH, str(workspace_id))
expected_path = os.path.join(parent_path, repo_dir_name)
logger.info(
"Creating workspace: id=%s name=%s repo=%s branch=%s target=%s",
workspace_id,
name,
repo.id,
branch,
expected_path,
)
# Remove stale directory from previous failed/aborted clone
if os.path.exists(path):
logger.warning("Removing stale workspace directory: %s", path)
shutil.rmtree(path, ignore_errors=True)
if os.path.exists(expected_path):
logger.warning("Removing stale workspace directory: %s", expected_path)
shutil.rmtree(expected_path, ignore_errors=True)
os.makedirs(parent_path, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
with contextlib.suppress(OSError):
os.chmod(parent_path, 0o777)
# Load SSH key if repo has one
ssh_key = None
@@ -108,19 +140,33 @@ class WorkspaceManager:
ssh_key_obj.private_key_encrypted.encode()
).decode()
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
self._make_world_writable(path)
try:
await GitService.clone(
repo.remote_url, branch, expected_path, ssh_key=ssh_key
)
except Exception as exc:
logger.error(
"Git clone failed for workspace %s (repo=%s, url=%s): %s",
workspace_id,
repo.id,
repo.remote_url,
exc,
)
raise
self._make_world_writable(expected_path)
workspace = Workspace(
id=workspace_id,
name=name,
repo_id=repo.id,
user_id=user_id,
branch=branch,
path=path,
path=expected_path,
status="ready",
last_sync_at=datetime.now(),
)
logger.info("Workspace created: %s", workspace.id)
logger.info("Workspace created: %s at %s", workspace.id, workspace.path)
return workspace
async def delete(
@@ -375,24 +421,30 @@ class WorkspaceManager:
if not os.path.exists(clone_path):
raise RuntimeError(f"Clone path not found: {clone_path}")
path = self._workspace_path(repo.id, name)
parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True)
workspace_id = uuid.uuid4()
parent_path = os.path.join(self.BASE_PATH, str(workspace_id))
repo_dir_name = self._repo_directory_name(repo)
target_path = os.path.join(parent_path, repo_dir_name)
os.makedirs(parent_path, exist_ok=True)
with contextlib.suppress(OSError):
os.chmod(parent, 0o777)
os.chmod(parent_path, 0o777)
if os.path.exists(path):
shutil.rmtree(path, ignore_errors=True)
if os.path.exists(target_path):
shutil.rmtree(target_path, ignore_errors=True)
shutil.move(clone_path, path)
self._make_world_writable(path)
# Move the existing clone into the repo-named subdirectory so the
# workspace path matches the natural git clone layout.
shutil.move(clone_path, target_path)
self._make_world_writable(target_path)
workspace = Workspace(
id=workspace_id,
name=name,
repo_id=repo.id,
user_id=instance.owner_id,
branch=instance.branch or "main",
path=path,
path=target_path,
status="ready",
last_sync_at=datetime.now(),
)
+6 -2
View File
@@ -2,12 +2,14 @@
dir: apps/api/src/services/tool
## role
Provides backend infrastructure for provisioning and managing isolated development tool instances with their dependencies and network access.
Provides infrastructure orchestration for isolated tool instances, managing their complete lifecycle from provisioning to teardown.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
## children
-
- apps/api/src/services/tool/.ruff_cache
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
map: apps/api/src/services/tool/.ruff_cache/.pi-map.md
## files
- instance_service.py
## links
@@ -16,5 +18,7 @@ map: apps/api/src/services/tool/.pi-map.md
## workflows
- change tool behavior
read: instance_service.py
- explore tool subdirectories
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
## dirty
-
File diff suppressed because one or more lines are too long
+244 -35
View File
@@ -5,16 +5,24 @@ import contextlib
import glob as glob_module
import logging
import os
import shutil
import subprocess
import uuid
from datetime import datetime
import httpx
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ConfigProfile, GitRepository, Project, SSHKey, ToolInstance, ToolType
from src.models import (
ConfigProfile,
GitRepository,
Project,
SSHKey,
ToolInstance,
ToolType,
Workspace,
)
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
from src.services.git.clone import check_dirty_state, clone_repository
from src.services.config.config_profile_resolver import (
@@ -64,15 +72,40 @@ from src.services.shared.permission_fixer import (
apply_ssh_permissions,
)
from src.services.shared.readiness_probe import execute_probe
from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
from src.services.shared.ssh_keys import prepare_ssh_key_files
from src.services.instance.event_bus import InstanceEventBus
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
from src.auth.dependencies import _get_owned_project, _get_user
from src.utils.git_url_parser import extract_base_repo_url
logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus()
def _get_repository_mount_name(
repo: GitRepository,
workspace: "Workspace | None" = None,
) -> str:
"""Return the directory name the repository should appear under in the container.
When a workspace exists, the on-disk layout is
``/data/working-copies/{workspace_id}/{repo_name}/``, so the repo-named
directory is already available as the basename of ``workspace.path``. For
legacy repo-only instances we fall back to parsing the remote URL like a
standard ``git clone`` would, then to the user-provided repository name.
"""
if workspace is not None and workspace.path:
return os.path.basename(os.path.normpath(workspace.path))
if repo.remote_url:
base_url = extract_base_repo_url(repo.remote_url) or repo.remote_url
name = base_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
if name:
return name
return repo.name
def _chown_path(path: str, uid: int, gid: int) -> None:
"""Recursively chown a path, suppressing permission errors."""
try:
@@ -107,6 +140,79 @@ def _chown_staged_mounts(
_chown_path(source, uid, gid)
def _relative_under(parent: str, child: str) -> str | None:
"""Return the relative path of ``child`` under ``parent`` if it is inside.
Returns ``""`` when the paths are equal. Returns ``None`` when ``child``
is not under ``parent``.
"""
parent = os.path.normpath(parent)
child = os.path.normpath(child)
if child == parent:
return ""
prefix = parent + os.sep
if child.startswith(prefix):
return child[len(prefix) :]
return None
def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict],
git_mount_volumes: list[dict],
) -> list[dict]:
"""Merge profile file mounts into overlapping git-mount sources.
When a config profile mounts static files to the same directory as a
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
would mask the cloned repository. Instead, copy the profile files into
the git-mount source directory so the container sees both sets of files
through a single bind mount.
Profile mounts whose target is a child of a git-mount target are copied
into the corresponding subdirectory. Mounts that do not overlap are
returned unchanged.
"""
remaining: list[dict] = []
for pvol in profile_mounts:
p_source = pvol.get("source", "")
p_target = pvol.get("target", "")
if not p_source or not os.path.exists(p_source):
remaining.append(pvol)
continue
merged = False
for gvol in git_mount_volumes:
g_source = gvol.get("source", "")
g_target = gvol.get("target", "")
if not g_source or not os.path.isdir(g_source):
continue
rel = _relative_under(g_target, p_target)
if rel is None:
continue
dst = os.path.join(g_source, rel) if rel else g_source
if os.path.isdir(p_source):
shutil.copytree(p_source, dst, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(p_source, dst)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
)
merged = True
break
if not merged:
remaining.append(pvol)
return remaining
async def resolve_git_mounts(
session: AsyncSession,
resolved: ResolvedProfile,
@@ -139,7 +245,7 @@ async def resolve_git_mounts(
if isinstance(result, Exception):
logger.warning("Git mount failed: %s", result)
continue
if result:
if isinstance(result, list):
volume_mounts.extend(result)
return volume_mounts
@@ -172,7 +278,12 @@ def clone_git_repo(
"""
import hashlib
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
# Include the branch in the hash so different branches of the same repo
# get separate clone directories and cannot race each other.
branch_segment = branch or "default"
url_hash = hashlib.md5(
f"{remote_url}:{branch_segment}".encode()
).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
repo_path = os.path.join(clone_dir, "repo-clone")
@@ -271,8 +382,20 @@ def resolve_git_mount_mappings(
# Single match: mount directly to target_path
mount_target = final_target
else:
# Multiple matches: append relative path to target
rel_path = os.path.relpath(matched_path, repo_path)
# Multiple matches: append the path relative to the glob's base
# directory so `packages/*` → `/app/packages` yields
# `/app/packages/api` instead of `/app/packages/packages/api`.
first_glob_idx = min(
(source_path.find(c) for c in "*?[" if c in source_path),
default=len(source_path),
)
base_relative = os.path.dirname(source_path[: first_glob_idx + 1])
base_full = (
os.path.join(repo_path, base_relative)
if base_relative
else repo_path
)
rel_path = os.path.relpath(matched_path, base_full)
mount_target = os.path.join(final_target, rel_path)
volume_mounts.append(
@@ -325,7 +448,14 @@ async def resolve_single_git_mount(
repo_path = await asyncio.to_thread(
clone_git_repo, remote_url, branch, instance_dir
)
except Exception:
except Exception as exc:
logger.warning(
"Git mount clone failed for %s (branch=%s): %s",
remote_url,
branch,
exc,
exc_info=True,
)
return []
# Resolve all mappings from the cloned repo
@@ -777,7 +907,6 @@ def ensure_backend_network_in_compose(compose_path: str) -> None:
logger.info("Injected backend network '%s' into compose file", network_name)
async def prepare_manifest_instance(
session: AsyncSession,
instance: ToolInstance,
@@ -883,12 +1012,27 @@ async def prepare_manifest_instance(
# The actual resolution happens in resolve_git_mounts; we store placeholder
git_mount_vars[f"GIT_MOUNT_{ref}"] = ""
# Use the repository name for the workspace/repo mount target, not the
# directory name of a workspace/clone path (which may be "main" or similar).
# Prefer the actual on-disk workspace directory name when a workspace is
# mounted, otherwise fall back to parsing the remote URL like git clone.
repo = await session.get(GitRepository, instance.repository_id)
workspace: Workspace | None = None
if instance.workspace_id:
workspace = await session.get(Workspace, instance.workspace_id)
repo_name = (
_get_repository_mount_name(repo, workspace)
if repo
else os.path.basename(os.path.normpath(repo_path))
)
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance.name.lower(),
"INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"REPO_NAME": repo_name,
"WORKSPACE_NAME": repo_name,
"SSH_PATH": ssh_path,
"TOOL_PORT": instance.port or 0,
"EXTRA_ENV": env_vars,
@@ -918,8 +1062,6 @@ async def prepare_manifest_instance(
return image_tag, compose_content, manifest, home_dir
async def create_tool_instance(
session: AsyncSession,
user_id: uuid.UUID,
@@ -1043,15 +1185,29 @@ async def create_tool_instance(
else ""
)
compose_content = f"""version: "3.8"\nservices:\n app:\n image: {image_tag}\n container_name: {instance_name.lower()}\n stdin_open: true\n tty: true\n{ports_section} volumes:\n - {repo_path}:/workspace\n restart: unless-stopped\n"""
home_dir = tool_type.home_directory or "/home/user"
mount_name = _get_repository_mount_name(repo, workspace)
workspace_target = f"{home_dir}/{mount_name}"
compose_content = f"""version: "3.8"\nservices:
app:
image: {image_tag}
container_name: {instance_name.lower()}
stdin_open: true
tty: true
{ports_section} environment:
- HOME={home_dir}
volumes:
- {repo_path}:{workspace_target}
working_dir: {workspace_target}
restart: "no"
"""
write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest":
from src.models import ToolDefinitionManifest
manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id
)
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if not manifest_def:
raise RuntimeError("Manifest definition not found for this tool type")
@@ -1061,20 +1217,21 @@ async def create_tool_instance(
ToolDefinitionManifest, manifest_def.base_definition_id
)
if base_def:
manifest = resolve_base(
deep_merge(dict(base_def.manifest), manifest)
)
manifest = resolve_base(deep_merge(dict(base_def.manifest), manifest))
image_tag = compute_image_tag(tool_type.name, manifest)
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
# deprecated alias for backward compatibility with older templates.
mount_name = _get_repository_mount_name(repo, workspace)
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
"INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"REPO_NAME": mount_name,
"WORKSPACE_NAME": mount_name,
"SSH_PATH": "",
"TOOL_PORT": tool_port,
"EXTRA_ENV": {},
@@ -1095,10 +1252,10 @@ async def create_tool_instance(
"TOOL_PORT": tool_port,
"USER_ID": str(user_id),
"PROJECT_ID": str(project_id),
"WORKSPACE_NAME": _get_repository_mount_name(repo, workspace),
"HOME_DIRECTORY": tool_type.home_directory or "/home/user",
}
compose_content = render_compose_template(
tool_type.compose_template, variables
)
compose_content = render_compose_template(tool_type.compose_template, variables)
write_compose_file(instance_dir, compose_content)
@@ -1182,7 +1339,7 @@ async def start_tool_instance(
# Fetch tool type early to determine home directory and container user
tool_type = await session.get(ToolType, instance.tool_type_id)
home_dir = "/root"
home_dir = tool_type.home_directory if tool_type and tool_type.home_directory else "/root"
container_uid = 0
container_gid = 0
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
@@ -1200,16 +1357,22 @@ async def start_tool_instance(
deep_merge(dict(base_def.manifest), manifest)
)
home_dir = get_manifest_home_dir(manifest)
runtime = manifest.get("runtime", {})
if runtime.get("working_dir"):
working_directory = expand_container_path(
runtime["working_dir"], home_dir
)
user_cfg = manifest.get("user")
if user_cfg:
container_uid = user_cfg.get("uid", 0)
container_gid = user_cfg.get("gid", 0)
logger.debug(
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s",
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s, working_directory=%s",
instance.id,
container_uid,
container_gid,
home_dir,
working_directory,
)
# Apply selected config profile if any
@@ -1222,27 +1385,37 @@ async def start_tool_instance(
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved, home_dir)
)
env_vars.update(profile_env)
config_files.update(profile_files)
extra_volumes.extend(profile_mounts)
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
extra_volumes.extend(git_mount_volumes)
# Profile hints override the manifest/tool defaults, and git mounts
# need the final working directory to resolve relative target paths.
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = profile_hints["working_directory"]
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
env_vars.update(profile_env)
config_files.update(profile_files)
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
# Stack static file mounts on top of git repo mounts so they do
# not mask each other when they target the same directory.
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
extra_volumes.extend(stacked_profile_mounts)
extra_volumes.extend(git_mount_volumes)
logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts),
)
except ConfigProfileCycleError as exc:
logger.error(
@@ -1665,6 +1838,41 @@ async def start_tool_instance(
logger.info("Readiness probe succeeded for instance %s", instance.id)
# Final stability check: the container must still be running after all
# post-start setup. If it has already exited/restarted, mark it failed now
# instead of optimistically reporting "running".
if instance.container_id:
final_check = get_container_status(instance.container_id)
if final_check["status"] != "running":
error_msg = (
f"Container stopped during startup: status={final_check['status']}"
)
if final_check["exit_code"] is not None:
error_msg += f", exit_code={final_check['exit_code']}"
logs = get_container_logs(instance.container_id, tail=50)
instance.status = "error"
await session.commit()
await publish_lifecycle_event(
event_bus=_event_bus,
session=session,
instance=instance,
event_type="instance.error",
created_by=user_id,
status="error",
message=error_msg,
metadata={
"exit_code": final_check["exit_code"],
"error_type": "container",
},
)
logger.error(
"Instance %s container stopped during startup: %s\nLogs:\n%s",
instance.id,
error_msg,
logs,
)
return {"status": "error", "error": error_msg, "logs": logs}
instance.status = "running"
await session.commit()
await publish_lifecycle_event(
@@ -2093,7 +2301,9 @@ async def stop_tool_instance(
try:
stop_tunnel(instance.name)
except Exception as exc:
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
logger.warning(
"Failed to stop tunnel for instance %s: %s", instance.id, exc
)
if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "stop")
@@ -2135,4 +2345,3 @@ async def rename_tool_instance(
await session.commit()
await session.refresh(instance)
return instance
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests
## role
Provides shared test infrastructure and fixtures for the FastAPI API application.
Provides shared test infrastructure and fixtures for the API application's test suite.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/tests
index: apps/api/tests/.pi-map.index.md
## role
Provides shared test infrastructure and fixtures for the FastAPI API application.
Provides shared test infrastructure and fixtures for the API application's test suite.
## files
- conftest.py | Provides shared pytest fixtures for testing a FastAPI application with async SQLite database, authenticated clients, and test data setup. | exp: func:test_client() → Generator[TestClient, None, None], call:create_async_engine, call:engine.begin, call:conn.run_sync, call:asyncio.run, call:init_db, call:async_sessionmaker, call:patch, call:TestClient, call:app.dependency_overrides.pop, call:engine.dispose, func:init_db(), call:engine.begin, call:conn.run_sync, func:override_get_db_session() → AsyncGenerator[AsyncSession, None], call:async_sessionmaker, func:db_session(test_client) → AsyncGenerator[AsyncSession, None], call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:gen.aclose, call:create_async_engine, call:engine.begin, call:conn.run_sync, call:async_sessionmaker, call:engine.dispose, func:authenticated_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_test_user, call:create_session_cookie, call:test_client.cookies.set, func:create_test_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, func:test_project_and_repo(authenticated_client) → tuple[str, str], call:uuid.uuid4, call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, call:asyncio.run, call:get_user_id, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, call:create_project_and_repo, call:str, raise:RuntimeError, func:get_user_id(), call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, func:create_project_and_repo(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, func:admin_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_admin_user, call:create_session_cookie, call:test_client.cookies.set, func:create_admin_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose | dep: asyncio, os, typing, unittest.mock, pytest, pytest_asyncio, fastapi.testclient, sqlalchemy.ext.asyncio, src.config, src.models.base, src.main, src.auth.dependencies, uuid, src.auth.session, src.models.user.user, src.models.project.project, src.models.project.git_repository, fastapi, sqlalchemy, aiosqlite, src.models, src.auth
## arch
Pytest fixture-based testing architecture using async SQLite in-memory database, dependency injection overrides, and async HTTP client setup for isolated integration tests.
Pytest fixture-based testing architecture with async SQLite test database, dependency injection overrides, and authenticated client factories using FastAPI's TestClient.
## tags
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
## symbols
+2 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests/integration
## role
Integration test suite for the API backend, covering authentication, workspaces, projects, notifications, configuration, and database models with real PostgreSQL and git repositories.
Integration and unit test suite for the API backend, covering authentication, CRUD APIs, workspace operations, git integration, notifications, and database model validation against real PostgreSQL and mocked dependencies.
## parent
index: apps/api/tests/.pi-map.index.md
map: apps/api/tests/.pi-map.md
@@ -21,6 +21,7 @@ map: apps/api/tests/.pi-map.md
- test_projects_api.py
- test_seed.py
- test_ssh_keys_api.py
- test_tool_instance_lifecycle.py
- test_tool_types_api.py
- test_tool_types_api_extended.py
- test_users_api.py
+3 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api/tests/integration
index: apps/api/tests/integration/.pi-map.index.md
## role
Integration test suite for the API backend, covering authentication, workspaces, projects, notifications, configuration, and database models with real PostgreSQL and git repositories.
Integration and unit test suite for the API backend, covering authentication, CRUD APIs, workspace operations, git integration, notifications, and database model validation against real PostgreSQL and mocked dependencies.
## files
- __init__.py | Empty file with no functionality
- test_auth_api.py | Integration tests for authentication API endpoints using a real PostgreSQL database | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_auth_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_login_redirects_to_authentik_authorize_endpoint() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_401_without_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.get, func:test_me_returns_user_with_valid_session() → None, call:_prepare_auth_test_db, call:_insert_test_user, call:_load_app, call:Settings, call:create_session_cookie, call:TestClient, call:client.get, call:response.json, func:test_logout_clears_session_cookie() → None, call:_prepare_auth_test_db, call:_load_app, call:TestClient, call:client.post, call:response.headers.get | dep: uuid, asyncio, importlib, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user.user, asyncpg, fastapi
@@ -18,6 +18,7 @@ Integration test suite for the API backend, covering authentication, workspaces,
- test_projects_api.py | Integration tests for a FastAPI projects API endpoint, verifying authentication, CRUD operations, and ownership-based authorization against a real PostgreSQL database. | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_project(project_id: str, owner_id: str, name) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:Project, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:Project, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_create_project_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.post, func:test_create_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_list_projects_returns_only_owned_projects() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, func:test_update_project_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch, func:test_update_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch, call:response.json, func:test_delete_project_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, func:test_delete_project_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, func:test_set_default_ssh_key_requires_ownership() → None, call:_prepare_test_db, call:_insert_user, call:_insert_project, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.patch | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.project.project, src.models.user.user, asyncpg, importlib, fastapi, src.database, src.api.user.auth, src.api.project.projects, src.main
- test_seed.py | Tests deterministic seed user generation and database seeding functionality for development environments. | exp: func:test_build_seed_user_returns_deterministic_payload() → None, call:build_seed_user, func:test_seed_database_creates_development_user(db_session: AsyncSession) → None, call:seed_database, call:db_session.scalar, call:select(User).where | dep: pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.models.user, src.scripts.seed
- test_ssh_keys_api.py | Integration tests verifying SSH key API endpoints require authentication | exp: func:test_create_ssh_key_requires_authentication(test_client: TestClient) → None, call:test_client.post, func:test_list_ssh_keys_requires_authentication(test_client: TestClient) → None, call:test_client.get | dep: pytest, fastapi.testclient
- test_tool_instance_lifecycle.py | Integration test placeholder for verifying Docker availability and container home directory lifecycle behavior | exp: func:_docker_available() → bool, call:subprocess.run, func:test_docker_available_placeholder() → None, call:_docker_available | dep: subprocess, pytest
- test_tool_types_api.py | Integration tests for a FastAPI tool types REST API endpoint using a real PostgreSQL database. | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:hasattr, call:asyncio.run, call:database_module.engine.dispose, call:importlib.reload, func:_mint_token(user_id: str) → str, call:Settings, call:create_session_cookie, call:datetime.now, call:timedelta, func:_insert_user(user_id: str, email) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_insert_tool_type(tool_type_id: str, name: str, display_name: str, compose_template: str, created_by_id) → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:async_sessionmaker, call:session_factory, call:ToolType, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:test_list_tool_types_requires_authentication() → None, call:_prepare_test_db, call:_load_app, call:TestClient, call:client.get, func:test_list_tool_types_returns_all_types() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, call:len, call:next, func:test_get_tool_type_by_id() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, call:response.json, func:test_get_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.get, func:test_create_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, call:response.json, func:test_create_tool_type_duplicate_name() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_invalid_yaml() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_services() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_create_tool_type_missing_required_variable() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.post, func:test_update_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, call:response.json, func:test_update_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.put, func:test_delete_tool_type_successfully() → None, call:_prepare_test_db, call:_insert_user, call:_insert_tool_type, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete, call:client.get, func:test_delete_tool_type_not_found() → None, call:_prepare_test_db, call:_insert_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_mint_token, call:client.delete | dep: uuid, datetime, asyncio, pytest, fastapi.testclient, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.tool.tool_type, src.models.user.user, asyncpg, importlib, fastapi
- test_tool_types_api_extended.py | Integration tests for a FastAPI tool types API endpoint covering CRUD operations with extended fields like dockerfile templates, readiness probes, startup commands, and validation. | exp: class:TestToolTypesAPIExtended, method:test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) → None, call:authenticated_client.post, method:test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_validate_tool_type_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json, method:test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, call:str, method:test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:response.json, method:test_update_tool_type_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.put, call:response.json, method:test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) → None, call:authenticated_client.post, call:create_response.json, call:authenticated_client.get, call:response.json | dep: pytest, fastapi.testclient, fastapi.testclient.TestClient
- test_users_api.py | Integration tests for user profile API endpoints using a real PostgreSQL database | exp: func:_postgres_available() → bool, call:asyncpg.connect, call:conn.close, call:asyncio.run, call:_check, func:_check() → bool, call:asyncpg.connect, call:conn.close, func:_prepare_users_test_db() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:connection.execute, call:text, call:engine.dispose, func:_load_app(), call:importlib.reload, func:_insert_test_user(user_id: str) → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, call:asyncio.run, call:_run, func:_run() → None, call:create_async_engine, call:build_database_url, call:engine.begin, call:connection.run_sync, call:async_sessionmaker, call:session_factory, call:User, call:uuid.UUID, call:session.merge, call:session.commit, call:engine.dispose, func:_create_auth_cookie(user_id: str) → str, call:Settings, call:create_session_cookie, func:test_get_profile_returns_401_without_cookie() → None, call:_prepare_users_test_db, call:_load_app, call:TestClient, call:client.get, func:test_get_profile_returns_user_data() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.get, call:response.json, func:test_update_profile_changes_name_and_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, call:response.json, func:test_update_profile_rejects_empty_name() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_update_profile_rejects_invalid_email() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.put, func:test_upload_avatar_updates_avatar_url() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, call:response.json, call:data["avatar_url"].startswith, func:test_upload_avatar_rejects_invalid_file_type() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO, func:test_upload_avatar_rejects_oversized_file() → None, call:_prepare_users_test_db, call:_insert_test_user, call:_load_app, call:TestClient, call:client.cookies.set, call:_create_auth_cookie, call:client.post, call:io.BytesIO | dep: uuid, datetime, asyncio, io, fastapi.testclient, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.session, src.config, src.models, src.models.user.user, asyncpg, importlib, fastapi, src.database, src.api.user.users, src.main
@@ -26,7 +27,7 @@ Integration test suite for the API backend, covering authentication, workspaces,
- test_workspace_instances.py | Integration tests for FastAPI workspace instance endpoints (list and create) using an authenticated test client and mocked database fixtures. | exp: class:TestListWorkspaceInstances, method:test_list_empty(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:authenticated_client.get, call:response.json, method:test_list_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_workspace_with_tool_type), call:ToolInstance, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_create_instance, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspaceInstance, method:test_create_instance(self, authenticated_client: TestClient, test_workspace_with_tool_type), call:ToolInstance, call:uuid.uuid4, call:datetime.now, call:patch, call:authenticated_client.post, call:str, call:response.json, func:_get_user_id(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_workspace_with_tool_type(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:tempfile.mkdtemp, call:Workspace, call:ToolType, call:db_session.commit, call:db_session.refresh | dep: asyncio, tempfile, uuid, datetime, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.auth.session, src.config, src.api.workspace.workspace_instances
- test_workspaces_api.py | Integration tests for workspace API endpoints including list, create, delete, and sync operations with repository/project scoping | exp: class:TestListWorkspaces, method:test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.get, call:response.json, method:test_list_with_workspaces(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:authenticated_client.get, call:response.json, call:len, class:TestCreateWorkspace, method:test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository), call:Workspace, call:uuid.uuid4, call:patch.object, call:authenticated_client.post, call:response.json, call:mock_create.assert_called_once, method:test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository), call:authenticated_client.post, call:response.json, method:test_create_duplicate_name(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:asyncio.run, call:_commit, call:patch.object, call:Exception, call:authenticated_client.post, class:TestDeleteWorkspace, method:test_delete_without_instances(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, call:response.json, method:test_delete_with_instances_force(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:authenticated_client.delete, class:TestSyncWorkspace, method:test_sync_success(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, method:test_sync_branch_deleted(self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository), call:Workspace, call:db_session.add, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_commit_refresh, call:patch.object, call:MagicMock, call:authenticated_client.post, call:response.json, func:_get_user_id_from_client(client: TestClient) → uuid.UUID, call:Settings, call:client.cookies.get, call:decode_session_cookie, call:uuid.UUID, raise:RuntimeError, func:test_repo(db_session: AsyncSession, authenticated_client: TestClient), call:_get_user_id_from_client, call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh, call:asyncio.run, call:_create, func:_create(), call:Project, call:db_session.add, call:db_session.flush, call:GitRepository, call:db_session.commit, call:db_session.refresh | dep: asyncio, uuid, unittest.mock, pytest, fastapi.testclient, sqlalchemy.ext.asyncio, src.models, src.services.shared.workspace_manager, src.auth.session, src.config, fastapi, sqlalchemy
## arch
Pytest-based integration testing using real external dependencies (PostgreSQL, git repos), FastAPI TestClient with authenticated fixtures, mocked database fixtures for isolation, and security validation patterns (cookie/ OIDC/ SSE auth).
Pytest-based integration testing with FastAPI TestClient, SQLAlchemy async sessions, real PostgreSQL database fixtures, and selective mocking; follows pattern of endpoint-per-test-file with authenticated client fixtures and seeded database state.
## tags
test, call:, call:db, call:authenticated, user, create, call:response.json, call:create
## symbols
@@ -0,0 +1,34 @@
"""Integration tests for tool container home directory behavior.
These tests exercise container lifecycle behavior and require Docker and a
running PostgreSQL database. They are skipped when Docker is unavailable.
"""
import subprocess
import pytest
def _docker_available() -> bool:
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except Exception:
return False
@pytest.mark.integration
@pytest.mark.skipif(not _docker_available(), reason="Docker not available")
def test_docker_available_placeholder() -> None:
"""Placeholder to keep the test file valid when Docker is present.
Real lifecycle tests (container starts with HOME=/home/user, /workspace
symlink works, git mounts are writable) should be added here once the
test harness can start the API and Docker services.
"""
assert _docker_available()
+9 -3
View File
@@ -2,14 +2,17 @@
dir: apps/api/tests/unit
## role
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
Contains unit tests for the API application's core services, utilities, and infrastructure components.
## parent
index: apps/api/tests/.pi-map.index.md
map: apps/api/tests/.pi-map.md
## children
-
- apps/api/tests/unit/.ruff_cache
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
map: apps/api/tests/unit/.ruff_cache/.pi-map.md
## files
- __init__.py
- test_alembic_migrations.py
- test_config.py
- test_config_profile_resolver.py
- test_docker_build.py
@@ -21,6 +24,7 @@ map: apps/api/tests/.pi-map.md
- test_git_url_parser.py
- test_health_monitor.py
- test_home_path_expansion.py
- test_instance_service.py
- test_lifecycle_hooks.py
- test_manifest_compiler.py
- test_migration_metadata.py
@@ -35,8 +39,10 @@ index: apps/api/tests/unit/.pi-map.index.md
map: apps/api/tests/unit/.pi-map.md
## workflows
- change unit behavior
read: __init__.py, test_config.py, test_config_profile_resolver.py
read: __init__.py, test_alembic_migrations.py, test_config.py
- change unit config
read: test_config.py, test_config_profile_resolver.py
- explore unit subdirectories
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
## dirty
-
+9 -5
View File
@@ -4,9 +4,10 @@ dir: apps/api/tests/unit
index: apps/api/tests/unit/.pi-map.index.md
## role
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
Contains unit tests for the API application's core services, utilities, and infrastructure components.
## files
- __init__.py | Empty file with no functionality
- test_alembic_migrations.py | Unit tests that verify Alembic database migrations are importable, have correct revision identifiers, and declare expected dependencies without requiring a live database. | exp: func:test_home_directory_migration_imports_and_rewrites() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable, func:test_merge_migration_resolves_heads() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable, func:test_remove_pi_agent_repo_mount_migration_imports() → None, call:Path, call:migration_path.exists, call:importlib.util.spec_from_file_location, call:importlib.util.module_from_spec, call:spec.loader.exec_module, call:callable | dep: importlib.util, pathlib, pytest, importlib
- test_config.py | Tests configuration settings and database URL building for an application, verifying defaults, environment variable overrides, and environment-specific behavior. | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database
- test_config_profile_resolver.py | Tests the config profile resolution system including merge helpers, profile inheritance with cycle detection, and git mount normalization | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_directory_not_individual_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path(volumes[0]["source"]).is_dir, call:(Path(volumes[0]["source"]) / "config.json").exists, call:(Path(volumes[0]["source"]) / "nested" / "file.txt").exists, method:test_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path, call:(Path(volumes[0]["source"]) / "z.json").exists, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:(Path(volumes[0]["source"]) / "app.toml").exists, call:Path, method:test_readonly_mount_sets_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, method:test_writable_mount_does_not_set_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, class:TestCheckIncludeCycle | dep: uuid, pathlib, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
- test_docker_build.py | Unit tests for a Docker image build service that verifies Dockerfile creation, command structure, context file handling, path traversal prevention, and error handling. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build
@@ -18,8 +19,9 @@ Contains comprehensive unit tests for the API backend services covering configur
- test_git_url_parser.py | Tests for git URL parsing utilities that extract base repository URLs, validate clone URLs, and parse various git URL formats across GitHub, GitLab, and Bitbucket. | exp: class:TestExtractBaseRepoUrl, method:test_github_tree_url(self), call:extract_base_repo_url, method:test_github_blob_url(self), call:extract_base_repo_url, method:test_github_pull_url(self), call:extract_base_repo_url, method:test_github_issues_url(self), call:extract_base_repo_url, method:test_github_valid_url(self), call:extract_base_repo_url, method:test_github_url_with_query_params(self), call:extract_base_repo_url, method:test_gitlab_tree_url(self), call:extract_base_repo_url, method:test_gitlab_blob_url(self), call:extract_base_repo_url, method:test_gitlab_merge_request_url(self), call:extract_base_repo_url, method:test_gitlab_valid_url(self), call:extract_base_repo_url, method:test_bitbucket_src_url(self), call:extract_base_repo_url, method:test_bitbucket_valid_url(self), call:extract_base_repo_url, method:test_ssh_url(self), call:extract_base_repo_url, method:test_ssh_url_without_git_suffix(self), call:extract_base_repo_url, method:test_invalid_url(self), call:extract_base_repo_url, method:test_empty_url(self), call:extract_base_repo_url, class:TestIsValidCloneUrl, method:test_valid_ssh_url(self), call:is_valid_clone_url, method:test_valid_https_url(self), call:is_valid_clone_url, method:test_browser_url(self), call:is_valid_clone_url, method:test_url_without_git_suffix(self), call:is_valid_clone_url, method:test_invalid_url(self), call:is_valid_clone_url, class:TestParseGitUrl, method:test_valid_git_url(self), call:parse_git_url, method:test_browser_url(self), call:parse_git_url, method:test_invalid_url(self), call:parse_git_url, method:test_empty_url(self), call:parse_git_url, method:test_ssh_url(self), call:parse_git_url | dep: src.utils.git_url_parser, pytest
- test_health_monitor.py | Unit tests for HealthMonitor state-transition logic covering container crash detection, tunnel failure detection, recovery detection, write deduplication, exception resilience, and start/stop lifecycle. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:health_monitor(event_bus: InstanceEventBus) → HealthMonitor, call:HealthMonitor, func:_create_running_instance(db_session) → ToolInstance, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, func:test_detects_container_crash(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_detects_tunnel_failure(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_detects_recovery(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:db_session.commit, call:HealthSnapshot, call:events_captured.append, call:event_bus.subscribe, call:patch, call:health_monitor._check_instance, call:db_session.refresh, call:len, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_skips_writes_when_no_state_change(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:HealthSnapshot, call:patch, call:health_monitor._check_instance, call:db_session.execute, call:select(HealthCheck).where, call:len, call:result.scalars().all, func:test_docker_exception_resilience(db_session, event_bus: InstanceEventBus, health_monitor: HealthMonitor) → None, call:_create_running_instance, call:events_captured.append, call:event_bus.subscribe, call:patch, call:RuntimeError, call:health_monitor._check_instance, call:db_session.execute, call:select(HealthCheck).where, call:result.scalar_one_or_none, func:capture_event(payload: InstanceEventPayload) → None, call:events_captured.append, func:test_monitor_start_stop(health_monitor: HealthMonitor) → None, call:health_monitor.start, call:task.done, call:health_monitor.stop, call:suppress, call:task.cancelled | dep: asyncio, uuid, contextlib, unittest.mock, pytest, sqlalchemy, src.models.system.health_check, src.models.tool.tool_instance, src.models.user.user, src.services.instance.event_bus, src.services.instance.health_monitor
- test_home_path_expansion.py | Unit tests for home directory path expansion (~ and $HOME) in container paths and manifest home directory resolution. | exp: class:TestExpandContainerPath, method:test_tilde_slash_expands(self) → None, call:expand_container_path, method:test_tilde_alone_expands(self) → None, call:expand_container_path, method:test_dollar_home_slash_expands(self) → None, call:expand_container_path, method:test_dollar_home_alone_expands(self) → None, call:expand_container_path, method:test_absolute_path_unchanged(self) → None, call:expand_container_path, method:test_relative_path_unchanged(self) → None, call:expand_container_path, method:test_tilde_in_middle_unchanged(self) → None, call:expand_container_path, method:test_dollar_home_in_middle_unchanged(self) → None, call:expand_container_path, method:test_root_home(self) → None, call:expand_container_path, class:TestGetManifestHomeDir, method:test_with_user_block(self) → None, call:get_manifest_home_dir, method:test_without_user_block(self) → None, call:get_manifest_home_dir, method:test_with_empty_user_name(self) → None, call:get_manifest_home_dir, method:test_with_none_user_name(self) → None, call:get_manifest_home_dir | dep: pytest, src.services.config.config_profile_resolver, src.services.build.manifest_compiler
- test_instance_service.py | Unit tests for tool instance service functions including compose file modification, repository mount name resolution, and manifest instance preparation | exp: class:TestModifyComposeFile, method:test_extra_volumes_expand_home_dir(self, tmp_path), call:compose_path.write_text, call:modify_compose_file, call:str, call:compose_path.read_text, method:test_working_directory_expands_home_dir(self, tmp_path), call:compose_path.write_text, call:modify_compose_file, call:str, call:compose_path.read_text, class:TestGetRepositoryMountName, method:test_prefers_remote_url_name_over_user_provided_name(self), call:MagicMock, call:_get_repository_mount_name, method:test_parses_browser_url_to_repo_name(self), call:MagicMock, call:_get_repository_mount_name, method:test_uses_workspace_path_basename_when_workspace_provided(self), call:MagicMock, call:_get_repository_mount_name, method:test_falls_back_to_repo_name_when_remote_url_missing(self), call:MagicMock, call:_get_repository_mount_name, method:test_falls_back_to_repo_name_for_unparseable_url(self), call:MagicMock, call:_get_repository_mount_name, class:Result, func:test_prepare_manifest_instance_uses_workspace_path_basename(), call:MagicMock, call:AsyncMock, call:Result, call:prepare_manifest_instance, func:session_get(model, obj_id), func:fake_run(cmd), call:Result | dep: unittest.mock, pytest, src.services.tool.instance_service, subprocess, src.services.tool
- test_lifecycle_hooks.py | Unit tests for lifecycle hook helper functions that derive notification titles and determine whether events should trigger notifications. | exp: class:TestDeriveTitle, method:test_known_event_types(self) → None, call:_derive_title, method:test_unknown_event_type(self) → None, call:_derive_title, class:TestShouldNotify, method:test_error_events_are_notified(self) → None, call:_should_notify, method:test_health_changed_running_is_notified(self) → None, call:_should_notify, method:test_created_started_stopped_restarted_deleted_filtered(self) → None, call:_should_notify, method:test_health_changed_non_running_filtered(self) → None, call:_should_notify | dep: pytest, src.services.instance.lifecycle_hooks
- test_manifest_compiler.py | Unit tests for a manifest compiler that generates Dockerfiles with user configuration and home directory setup | exp: func:test_compile_dockerfile_creates_config_dirs_for_user() → None, call:compile_dockerfile, func:test_compile_dockerfile_no_user_does_not_create_home() → None, call:compile_dockerfile | dep: pytest, src.services.build.manifest_compiler
- test_manifest_compiler.py | Unit tests for a manifest compiler that generates Dockerfiles, docker-compose files, and entrypoint scripts from manifest configurations. | exp: class:TestGetManifestHomeDir, method:test_home_directory_in_manifest_wins(self) → None, call:get_manifest_home_dir, method:test_user_name_derives_home(self) → None, call:get_manifest_home_dir, method:test_root_fallback(self) → None, call:get_manifest_home_dir, method:test_empty_home_directory_falls_back(self) → None, call:get_manifest_home_dir, class:TestCompileDockerfileHomeDirectory, method:test_env_home_and_workdir_use_home_directory(self) → None, call:compile_dockerfile, method:test_workspace_symlink_created(self) → None, call:compile_dockerfile, method:test_runtime_workspace_not_baked_into_image(self) → None, call:compile_dockerfile, method:test_runtime_working_dir_overrides_home_workdir(self) → None, call:compile_dockerfile, method:test_working_dir_expands_tilde(self) → None, call:compile_dockerfile, class:TestCompileComposeHomeDirectory, method:test_default_repo_mount_synthesized(self) → None, call:compile_compose, method:test_explicit_repo_mount_preserved(self) → None, call:compile_compose, method:test_workspace_name_substituted_in_mount_target(self) → None, call:compile_compose, method:test_working_dir_expands_home(self) → None, call:compile_compose, class:TestCompileEntrypoint, method:test_entrypoint_creates_home_and_workspace(self) → None, call:compile_entrypoint, method:test_entrypoint_removes_stale_placeholder_directory(self) → None, call:compile_entrypoint, method:test_entrypoint_uses_root_then_sudo_for_workspace_symlink(self) → None, call:compile_entrypoint, call:entrypoint.find, method:test_entrypoint_fixes_mount_owners(self) → None, call:compile_entrypoint, func:test_compile_dockerfile_creates_config_dirs_for_user() → None, call:compile_dockerfile, func:test_compile_dockerfile_no_user_does_not_create_home() → None, call:compile_dockerfile, func:test_compile_dockerfile_uses_user_npm_prefix() → None, call:compile_dockerfile, func:test_compile_dockerfile_starts_as_root_and_drops_privileges() → None, call:compile_dockerfile, call:compile_entrypoint, func:test_compile_compose_runs_as_root() → None, call:compile_compose | dep: pytest, src.services.build.manifest_compiler
- test_migration_metadata.py | Tests Alembic database migration files for correct table definitions and revision chain metadata | exp: func:test_initial_migration_defines_all_core_tables() → None, call:Path(__file__).resolve, call:spec_from_file_location, call:module_from_spec, call:spec.loader.exec_module, func:test_refresh_tokens_migration_has_expected_revision_chain() → None, call:Path(__file__).resolve, call:spec_from_file_location, call:module_from_spec, call:spec.loader.exec_module | dep: pytest, importlib.util, pathlib, pathlib.Path
- test_monitoring_models.py | Unit tests verifying creation, persistence, and querying of monitoring models (InstanceEvent and HealthCheck) with database migration compatibility. | exp: func:test_instance_event_creation(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:InstanceEvent, call:db_session.refresh, call:isinstance, func:test_health_check_creation(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:HealthCheck, call:db_session.refresh, call:isinstance, func:test_instance_event_query_by_instance(db_session) → None, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, call:ToolInstance, call:InstanceEvent, call:db_session.execute, call:select(InstanceEvent).where, call:result.scalar_one | dep: uuid, datetime, pytest, sqlalchemy, src.models.system.health_check, src.models.system.instance_event, src.models.tool.tool_instance, src.models.user.user
- test_notification_service.py | Unit tests for NotificationService covering CRUD operations, filtering, sorting, and ownership isolation. | exp: func:notification_service() → NotificationService, call:NotificationService, func:user_a(db_session: AsyncSession) → User, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, func:user_b(db_session: AsyncSession) → User, call:User, call:uuid.uuid4, call:db_session.add, call:db_session.commit, func:test_create_notification(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:uuid.uuid4, func:test_list_notifications_orders_by_created_at_desc(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:datetime.now, call:timedelta, call:db_session.commit, call:db_session.refresh, call:notification_service.list_notifications, func:test_list_notifications_excludes_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:notification_service.list_notifications, func:test_list_notifications_unread_only(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.mark_read, call:notification_service.list_notifications, func:test_get_unread_count(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_read, call:notification_service.get_unread_count, func:test_mark_read_sets_read_at(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.mark_read, func:test_mark_all_read_affects_all_unread(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_all_read, call:notification_service.get_unread_count, func:test_dismiss_sets_dismissed_at(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:db_session.execute, call:select(Notification).where, call:result.scalar_one, func:test_mark_read_wrong_owner_raises(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:notification_service.create_notification, call:pytest.raises, call:notification_service.mark_read, func:test_dismiss_wrong_owner_raises(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:notification_service.create_notification, call:pytest.raises, call:notification_service.dismiss, func:test_list_notifications_mute_categories(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.list_notifications, func:test_get_unread_count_excludes_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:notification_service.create_notification, call:notification_service.dismiss, call:notification_service.get_unread_count, func:test_dismiss_all_affects_all_non_dismissed(db_session: AsyncSession, notification_service: NotificationService, user_a: User) → None, call:range, call:notification_service.create_notification, call:notification_service.dismiss_all, call:notification_service.list_notifications, func:test_dismiss_all_affects_only_caller(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:range, call:notification_service.create_notification, call:notification_service.dismiss_all, call:notification_service.list_notifications, func:test_mark_all_read_affects_only_caller(db_session: AsyncSession, notification_service: NotificationService, user_a: User, user_b: User) → None, call:range, call:notification_service.create_notification, call:notification_service.mark_all_read, call:notification_service.get_unread_count | dep: uuid, datetime, pytest, sqlalchemy, sqlalchemy.ext.asyncio, src.models.system.notification, src.models.user.user, src.services.shared.notification_service, NotificationService, Notification, User, AsyncSession
@@ -28,9 +30,9 @@ Contains comprehensive unit tests for the API backend services covering configur
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands via docker exec with retry logic. | exp: class:TestExecuteProbe, class:TestIntegrationScenarios | dep: unittest.mock, src.services.shared.readiness_probe, subprocess
- test_ssh_keys.py | Unit tests for SSH key preparation functionality including file creation, permissions, ownership, and error handling | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.shared.ssh_keys
## arch
Standard Python unittest/pytest pattern with heavy mocking of external dependencies (subprocess, docker, filesystem) to test service layer logic in isolation, organized by functional domain with one test module per service component.
Comprehensive unit test suite using mocked dependencies to test business logic in isolation, covering database migrations, configuration, Docker operations, Git workflows, file services, event handling, health monitoring, and SSH/security utilities.
## tags
test, url, call:notification, git, call:, call:db, merge, src
test, url, call:notification, git, call:, home, merge, call:db
## symbols
- TestMergeFunctions
- TestResolveProfile
@@ -42,8 +44,10 @@ test, url, call:notification, git, call:, call:db, merge, src
- TestSortVolumesBySpecificity
## workflows
- change unit behavior
read: __init__.py, test_config.py, test_config_profile_resolver.py
read: __init__.py, test_alembic_migrations.py, test_config.py
- change unit config
read: test_config.py, test_config_profile_resolver.py
- explore unit subdirectories
index: apps/api/tests/unit/.ruff_cache/.pi-map.index.md
## dirty
-
@@ -0,0 +1,69 @@
"""Unit tests for Alembic migration structure/import.
Actual upgrade/downgrade round-trips require a PostgreSQL database, so these
tests verify that migrations are importable, have the expected identifiers,
and declare the expected dependencies.
"""
import importlib.util
from pathlib import Path
import pytest
@pytest.mark.unit
def test_home_directory_migration_imports_and_rewrites() -> None:
migration_path = Path(__file__).parent.parent.parent / (
"alembic/versions/2026_06_14_104415_add_tool_type_home_directory.py"
)
assert migration_path.exists()
spec = importlib.util.spec_from_file_location("home_dir_migration", migration_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "2026_06_14_104415"
assert module.down_revision == "f3d2dc90ba3a"
assert callable(module.upgrade)
assert callable(module.downgrade)
assert module.OLD_WORKSPACE == "/workspace"
assert module.NEW_WORKSPACE == "/home/user/{{WORKSPACE_NAME}}"
@pytest.mark.unit
def test_merge_migration_resolves_heads() -> None:
migration_path = Path(__file__).parent.parent.parent / (
"alembic/versions/fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py"
)
assert migration_path.exists()
spec = importlib.util.spec_from_file_location("merge_migration", migration_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "fc8f1a20cbf6"
assert "2026_06_14_104415" in module.down_revision
assert "8c6d1dbd4798" in module.down_revision
assert callable(module.upgrade)
@pytest.mark.unit
def test_remove_pi_agent_repo_mount_migration_imports() -> None:
migration_path = Path(__file__).parent.parent.parent / (
"alembic/versions/2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py"
)
assert migration_path.exists()
spec = importlib.util.spec_from_file_location(
"remove_repo_mount_migration", migration_path
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "2026_06_15_090500"
assert module.down_revision == "2026_06_14_182955"
assert callable(module.upgrade)
assert callable(module.downgrade)
@@ -0,0 +1,328 @@
"""Unit tests for the tool instance service."""
from unittest.mock import MagicMock, AsyncMock
import pytest
from src.services.tool.instance_service import (
_get_repository_mount_name,
_stack_profile_mounts_with_git_mounts,
modify_compose_file,
prepare_manifest_instance,
)
@pytest.mark.unit
class TestModifyComposeFile:
"""Tests for modify_compose_file home-directory expansion."""
def test_extra_volumes_expand_home_dir(self, tmp_path):
compose_path = tmp_path / "docker-compose.yml"
compose_path.write_text(
"services:\n app:\n image: test:latest\n volumes: []\n"
)
modify_compose_file(
str(compose_path),
extra_volumes=[
{"source": "/host/config", "target": "~/.config", "type": "bind"},
{"source": "/host/code", "target": "$HOME/code", "type": "bind"},
],
home_dir="/home/user",
)
content = compose_path.read_text()
assert "/host/config:/home/user/.config" in content
assert "/host/code:/home/user/code" in content
def test_working_directory_expands_home_dir(self, tmp_path):
compose_path = tmp_path / "docker-compose.yml"
compose_path.write_text("services:\n app:\n image: test:latest\n")
modify_compose_file(
str(compose_path),
working_directory="~/workspace",
home_dir="/home/user",
)
content = compose_path.read_text()
assert "working_dir: /home/user/workspace" in content
@pytest.mark.unit
class TestGetRepositoryMountName:
"""Tests for _get_repository_mount_name."""
def test_prefers_remote_url_name_over_user_provided_name(self):
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
assert _get_repository_mount_name(repo) == "headquarter"
def test_parses_browser_url_to_repo_name(self):
repo = MagicMock()
repo.name = "src"
repo.remote_url = "https://github.com/acme/headquarter/tree/main"
assert _get_repository_mount_name(repo) == "headquarter"
def test_uses_workspace_path_basename_when_workspace_provided(self):
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
workspace = MagicMock()
workspace.path = "/data/working-copies/uuid/headquarter"
assert _get_repository_mount_name(repo, workspace) == "headquarter"
def test_falls_back_to_repo_name_when_remote_url_missing(self):
repo = MagicMock()
repo.name = "my-cool-repo"
repo.remote_url = None
assert _get_repository_mount_name(repo) == "my-cool-repo"
def test_falls_back_to_repo_name_for_unparseable_url(self):
repo = MagicMock()
repo.name = "my-cool-repo"
repo.remote_url = ""
assert _get_repository_mount_name(repo) == "my-cool-repo"
@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts."""
def test_exact_overlap_merges_profile_files_into_git_source(
self, tmp_path
) -> None:
"""When a profile mount targets the same directory as a git mount,
the profile files should be copied into the git-mount source so the
container sees both sets of files through one bind mount."""
git_source = tmp_path / "git" / "repo-clone"
git_source.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git")
profile_source = tmp_path / "profile" / "home_user_.pi"
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
"readonly": False,
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "existing.txt").read_text() == "from git"
assert (git_source / "settings.json").read_text() == "{}"
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
"""Profile mounts targeting a child directory are copied into the
corresponding subdirectory of the git-mount source."""
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "profile" / "agent"
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("x")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/agent",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "agent" / "settings.json").read_text() == "x"
assert (git_source / "README").read_text() == "repo"
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
"""Profile mounts that do not overlap a git mount are returned as-is."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config").write_text("c")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.config",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_git_source_file_does_not_consume_profile_mount(self, tmp_path) -> None:
"""If the overlapping git-mount source is a file, the profile mount
cannot be merged and must be kept."""
git_source = tmp_path / "file.txt"
git_source.write_text("file")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
]
git_mount_volumes = [
{
"source": str(git_source),
"target": "/home/user/.pi/file.txt",
"type": "bind",
}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
"""A profile mount that supplies a single file is copied into the
git-mount source directory."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "settings.json"
profile_source.write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "settings.json").read_text() == "{}"
@pytest.mark.unit
async def test_prepare_manifest_instance_uses_workspace_path_basename():
"""WORKSPACE_NAME must be the repo-named workspace directory, not workspace.name."""
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
repo.path = "/data/repos/main"
workspace = MagicMock()
workspace.id = "workspace-uuid"
workspace.repo_id = "repo-uuid"
workspace.name = "main"
workspace.path = "/data/working-copies/workspace-uuid/headquarter"
tool_type = MagicMock()
tool_type.name = "pi-agent"
tool_type.manifest_id = "manifest-uuid"
manifest_def = MagicMock()
manifest_def.id = "manifest-uuid"
manifest_def.manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "user", "uid": 1001, "gid": 1001},
}
manifest_def.base_definition_id = None
instance = MagicMock()
instance.id = "instance-uuid"
instance.name = "pi-agent-headquarter-abc123"
instance.repository_id = "repo-uuid"
instance.tool_type_id = "tooltype-uuid"
instance.port = 0
instance.selected_config_profile_id = None
instance.workspace_id = "workspace-uuid"
session = AsyncMock()
async def session_get(model, obj_id):
if model.__name__ == "ToolType":
return tool_type
if model.__name__ == "ToolDefinitionManifest":
return manifest_def
if model.__name__ == "GitRepository":
return repo
if model.__name__ == "Workspace":
return workspace
return None
session.get.side_effect = session_get
# Patch docker images check to report the image already exists so we skip
# the actual Docker build.
import subprocess
from src.services.tool import instance_service
original_run = subprocess.run
def fake_run(cmd, **kwargs):
class Result:
returncode = 0
stdout = "image-id"
stderr = ""
return Result()
instance_service.subprocess.run = fake_run
try:
(
image_tag,
compose_content,
manifest,
home_dir,
) = await prepare_manifest_instance(
session=session,
instance=instance,
instance_dir="/tmp/instance",
repo_path="/data/working-copies/workspace-uuid/headquarter",
env_vars={},
extra_volumes=[],
working_directory=None,
)
finally:
instance_service.subprocess.run = original_run
assert "WORKSPACE_NAME: headquarter" in compose_content
assert "/data/working-copies/workspace-uuid/headquarter:/home/user/headquarter" in compose_content
+327 -4
View File
@@ -2,7 +2,12 @@
import pytest
from src.services.build.manifest_compiler import compile_dockerfile
from src.services.build.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
get_manifest_home_dir,
)
@pytest.mark.unit
@@ -19,9 +24,16 @@ def test_compile_dockerfile_creates_config_dirs_for_user() -> None:
assert "groupadd -g 1000 dev" in dockerfile
assert "useradd -u 1000 -g 1000 -m -s /bin/bash dev" in dockerfile
assert "mkdir -p /home/dev && chown -R dev:dev /home/dev" in dockerfile
assert "mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile
assert "mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share" in dockerfile
assert "mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
assert (
"mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile
)
assert (
"mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share"
in dockerfile
)
assert (
"mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
)
@pytest.mark.unit
@@ -36,3 +48,314 @@ def test_compile_dockerfile_no_user_does_not_create_home() -> None:
assert "useradd" not in dockerfile
assert "/home/" not in dockerfile
@pytest.mark.unit
class TestGetManifestHomeDir:
"""Tests for get_manifest_home_dir precedence."""
def test_home_directory_in_manifest_wins(self) -> None:
manifest = {
"home_directory": "/home/custom",
"user": {"name": "dev"},
}
assert get_manifest_home_dir(manifest) == "/home/custom"
def test_user_name_derives_home(self) -> None:
manifest = {"user": {"name": "dev", "uid": 1000, "gid": 1000}}
assert get_manifest_home_dir(manifest) == "/home/dev"
def test_root_fallback(self) -> None:
manifest = {"base_image": "ubuntu:24.04"}
assert get_manifest_home_dir(manifest) == "/root"
def test_empty_home_directory_falls_back(self) -> None:
manifest = {"home_directory": "", "user": {"name": "dev"}}
assert get_manifest_home_dir(manifest) == "/home/dev"
@pytest.mark.unit
class TestCompileDockerfileHomeDirectory:
"""Tests that compile_dockerfile honors manifest.home_directory."""
def test_env_home_and_workdir_use_home_directory(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
assert "ENV HOME=/home/custom" in dockerfile
assert "WORKDIR /home/custom" in dockerfile
def test_workspace_symlink_created(self) -> None:
"""When workspace name is known at build time, create /workspace symlink."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"workspace_name": "my-app",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
assert "mkdir -p /home/custom" in dockerfile
assert "ln -sfn /home/custom/my-app /workspace" in dockerfile
def test_runtime_workspace_not_baked_into_image(self) -> None:
"""When workspace name is a runtime placeholder, do not create literal
{{WORKSPACE_NAME}} directories or symlinks in the image."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [{"source_type": "repo", "target": "~/{{WORKSPACE_NAME}}"}],
}
dockerfile = compile_dockerfile(manifest)
assert "{{WORKSPACE_NAME}}" not in dockerfile
assert "ln -sfn" not in dockerfile
def test_runtime_working_dir_overrides_home_workdir(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"runtime": {"working_dir": "/app/code"},
}
dockerfile = compile_dockerfile(manifest)
assert "WORKDIR /app/code" in dockerfile
assert "WORKDIR /home/custom" not in dockerfile
def test_working_dir_expands_tilde(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"runtime": {"working_dir": "~/code"},
}
dockerfile = compile_dockerfile(manifest)
assert "WORKDIR /home/custom/code" in dockerfile
@pytest.mark.unit
class TestCompileComposeHomeDirectory:
"""Tests that compile_compose uses home_directory for volumes and working_dir."""
def test_default_repo_mount_synthesized(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "/host/repos/my-app:/home/custom/my-app" in compose
def test_explicit_repo_mount_preserved(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [
{"source_type": "repo", "target": "/opt/code", "readonly": True}
],
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "/host/repos/my-app:/opt/code:ro" in compose
assert "/home/custom/my-app" not in compose
def test_workspace_name_substituted_in_mount_target(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [{"source_type": "repo", "target": "~/{{WORKSPACE_NAME}}"}],
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "/host/repos/my-app:/home/custom/my-app" in compose
assert "WORKSPACE_NAME: my-app" in compose
def test_working_dir_expands_home(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"runtime": {"working_dir": "$HOME/code"},
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "working_dir: /home/custom/code" in compose
@pytest.mark.unit
def test_compile_dockerfile_uses_user_npm_prefix() -> None:
"""npm global packages must be installed into a user-writable prefix."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"packages": {"npm_global": ["@scope/pkg"]},
}
dockerfile = compile_dockerfile(manifest)
assert "npm install -g --prefix /home/custom/.npm-global" in dockerfile
assert "/home/custom/.npm-global/bin:$PATH" in dockerfile
assert "ENV PATH=/home/custom/.npm-global/bin:$PATH" in dockerfile
@pytest.mark.unit
def test_compile_dockerfile_starts_as_root_and_drops_privileges() -> None:
"""The Dockerfile must not set USER so the entrypoint starts as root.
The entrypoint itself drops privileges to the container user before
exec-ing the real command.
"""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
assert "USER dev" not in dockerfile
assert 'exec runuser -u dev -- /bin/bash -il' in entrypoint
assert 'exec runuser -u dev -- "$@"' in entrypoint
@pytest.mark.unit
def test_compile_compose_runs_as_root() -> None:
"""The compose service must start as root so the entrypoint can fix /workspace."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
variables = {
"IMAGE_TAG": "test:latest",
"INSTANCE_NAME": "test-instance",
"REPO_PATH": "/host/repos/my-app",
"WORKSPACE_NAME": "my-app",
"TOOL_PORT": 0,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, variables)
assert "user: 0:0" in compose
@pytest.mark.unit
class TestCompileEntrypoint:
"""Tests for the generated permission-fixing entrypoint."""
def test_entrypoint_creates_home_and_workspace(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
entrypoint = compile_entrypoint(manifest)
assert 'mkdir -p "$HOME_DIR"' in entrypoint
assert 'mkdir -p "$WORKSPACE_TARGET"' in entrypoint
assert 'ln -sfn "$WORKSPACE_TARGET" /workspace' in entrypoint
assert 'WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"' in entrypoint
def test_entrypoint_removes_stale_placeholder_directory(self) -> None:
"""Older images baked in a literal {{WORKSPACE_NAME}} directory."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
entrypoint = compile_entrypoint(manifest)
assert 'if [ -d "${HOME_DIR}/{{WORKSPACE_NAME}}" ]; then' in entrypoint
assert 'rm -rf "${HOME_DIR}/{{WORKSPACE_NAME}}"' in entrypoint
def test_entrypoint_uses_root_then_sudo_for_workspace_symlink(self) -> None:
"""/workspace is under /, so root takes precedence; non-root falls back to sudo."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
entrypoint = compile_entrypoint(manifest)
root_idx = entrypoint.find('if [ "$(id -u)" = "0" ]; then')
sudo_idx = entrypoint.find('elif [ -n "$SUDO" ]; then')
assert root_idx != -1
assert sudo_idx != -1
assert root_idx < sudo_idx
assert 'sudo ln -sfn "$WORKSPACE_TARGET" /workspace' in entrypoint
def test_entrypoint_fixes_mount_owners(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
"mounts": [
{"target": "~/.config", "readonly": False},
{"target": "/opt/readonly", "readonly": True},
],
}
entrypoint = compile_entrypoint(manifest)
assert 'fix_owner "/home/custom/.config"' in entrypoint
assert 'fix_owner "/opt/readonly"' not in entrypoint
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web
## role
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
Frontend web application providing the browser-based user interface for the project, built with React/Vite and served via nginx.
## parent
index: apps/.pi-map.index.md
map: apps/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web
index: apps/web/.pi-map.index.md
## role
Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project.
Frontend web application providing the browser-based user interface for the project, built with React/Vite and served via nginx.
## files
- .env.example | Template file defining example environment variables for frontend API and application URL configuration
- .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ECMAScript module support | dep: @typescript-eslint/parser, @typescript-eslint/eslint-plugin, eslint
@@ -16,7 +16,7 @@ Frontend web application providing a React-based UI with code editing, terminal,
- tsconfig.json | TypeScript configuration file for a React project using Vite with modern ES2020 target and bundler module resolution | dep: typescript, react, vite
- vite.config.ts | Configures Vite build tool for a React project with custom dev server port and Vitest test settings. | dep: vite, @vitejs/plugin-react
## arch
Modern React SPA built with Vite and TypeScript, using nginx for production serving with client-side routing, multi-stage Docker deployment, and Vitest for testing.
Modern SPA architecture using Vite for bundling, TypeScript for type safety, React for UI components, with containerized nginx deployment and environment-driven configuration.
## tags
react, eslint, vite, typescript, dom, application, nginx, web
## symbols
+512
View File
@@ -897,6 +897,24 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -914,6 +932,24 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -931,6 +967,24 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -6054,6 +6108,420 @@
}
}
},
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
@@ -6081,6 +6549,50 @@
}
}
},
"node_modules/vitest/node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src
## role
Frontend web application entry point and core infrastructure for a React-based collaborative development platform.
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend.
## parent
index: apps/web/.pi-map.index.md
map: apps/web/.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: apps/web/src
index: apps/web/src/.pi-map.index.md
## role
Frontend web application entry point and core infrastructure for a React-based collaborative development platform.
Provides the core web application entry point, routing infrastructure, and shared domain type definitions for a React-based frontend.
## files
- main.tsx | Bootstraps a React application with routing, authentication, and session management providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom
- router.tsx | Defines the React Router configuration for a web application with protected routes, nested layouts, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage
- types.ts | Defines TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
## arch
Modular React SPA using React Router v6 with nested route layouts, protected route guards via authentication context, and centralized TypeScript domain models for session/workspace/project entities.
Implements a modular React SPA architecture using React Router v6 with nested route layouts, protected route guards via authentication/session providers, and centralized TypeScript domain modeling for cross-cutting concerns.
## tags
pages, styles, css, router, react, session, dom, project
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/components
## role
Provides reusable, accessible UI components and utilities for rendering the application shell, data states, icons, code display, notifications, and route protection in a React web application.
Provides reusable, accessible UI primitives and layout components for a React web application, including shell layout, data states, icons, code editing, routing guards, and toast notifications.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/web/src/components
index: apps/web/src/components/.pi-map.index.md
## role
Provides reusable, accessible UI components and utilities for rendering the application shell, data states, icons, code display, notifications, and route protection in a React web application.
Provides reusable, accessible UI primitives and layout components for a React web application, including shell layout, data states, icons, code editing, routing guards, and toast notifications.
## files
- app-shell.tsx | Renders the main application shell layout with navigation, header, session management, and mobile-responsive behavior for a React Router-based SPA. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
- code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
@@ -17,7 +17,7 @@ Provides reusable, accessible UI components and utilities for rendering the appl
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast state module, InstanceEventPayload type
## arch
Component-based React architecture with functional components, composition patterns, separation of concerns (presentation vs logic), and test coverage for critical utilities; integrates third-party libraries (Phosphor icons, react-simple-code-editor) and React Router for SPA navigation.
Component-based React architecture with functional components, composition patterns, and separation of concerns between presentation (UI rendering), logic (rules/hooks), and testing; integrates third-party libraries (react-simple-code-editor, Phosphor icons) and React Router for SPA navigation.
## tags
toast, icon, react, state, code, loading, event, editor
## symbols
+20 -9
View File
@@ -46,9 +46,13 @@ const SessionItem = ({ session }: { session: Session }) => {
? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`;
const tooltipParts = [session.display_name, session.project_name];
if (session.workspace_name) tooltipParts.push(session.workspace_name);
else if (session.repository_name) tooltipParts.push(session.repository_name);
const contextName = session.workspace_name || session.repository_name;
const tooltipParts = [
session.display_name,
session.tool_type_name,
session.project_name,
];
if (contextName) tooltipParts.push(contextName);
tooltipParts.push(`(${session.status})`);
return (
@@ -57,13 +61,20 @@ const SessionItem = ({ session }: { session: Session }) => {
target={`session-${session.id}`}
rel="noreferrer"
className="nav-item session-item"
title={tooltipParts.join(" ")}
title={tooltipParts.join(" · ")}
>
<span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">
{session.display_name}
<span className="session-tool">{session.tool_type_name}</span>
<span className="session-icon-wrap">
<Icon name={session.tool_icon as IconName} size="sm" />
<span className={`session-status ${isRunning ? "running" : ""}`} />
</span>
<span className="session-meta">
{contextName && (
<span className="session-workspace">{contextName}</span>
)}
<span className="session-display-name">{session.display_name}</span>
<span className="session-context">
{session.tool_type_name} · {session.project_name}
</span>
</span>
</a>
);
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features
## role
Contains reusable React components that implement specific user-facing features and functionality across the web application.
Contains reusable React components that implement specific product features and user-facing functionality across the web application.
## parent
index: apps/web/src/components/.pi-map.index.md
map: apps/web/src/components/.pi-map.md
@@ -19,6 +19,9 @@ map: apps/web/src/components/.pi-map.md
- apps/web/src/components/features/notification
index: apps/web/src/components/features/notification/.pi-map.index.md
map: apps/web/src/components/features/notification/.pi-map.md
- apps/web/src/components/features/profile
index: apps/web/src/components/features/profile/.pi-map.index.md
map: apps/web/src/components/features/profile/.pi-map.md
- apps/web/src/components/features/project
index: apps/web/src/components/features/project/.pi-map.index.md
map: apps/web/src/components/features/project/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
index: apps/web/src/components/features/.pi-map.index.md
## role
Contains reusable React components that implement specific user-facing features and functionality across the web application.
Contains reusable React components that implement specific product features and user-facing functionality across the web application.
## files
## arch
Feature-based component organization with domain-specific UI building blocks following React composition patterns, likely co-located with related hooks, utilities, or sub-components for each feature area.
Feature-based component organization with domain-specific groupings, likely combining presentational and container patterns with hooks for state management, following a modular architecture where each feature encapsulates its own UI, logic, and data fetching concerns.
## tags
-
## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/config-profiles
## role
Provides a complete UI subsystem for managing configuration profiles with desktop and mobile views, supporting CRUD operations, profile relationships, and advanced configuration options.
Provides UI components for managing Docker configuration profiles including creation, editing, listing, and mobile-responsive views.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
@@ -4,15 +4,15 @@ dir: apps/web/src/components/features/config-profiles
index: apps/web/src/components/features/config-profiles/.pi-map.index.md
## role
Provides a complete UI subsystem for managing configuration profiles with desktop and mobile views, supporting CRUD operations, profile relationships, and advanced configuration options.
Provides UI components for managing Docker configuration profiles including creation, editing, listing, and mobile-responsive views.
## files
- ConfigProfileEditorPanel.tsx | React component that renders a form-based editor panel for creating and editing configuration profiles with support for includes, environment variables, runtime hints, files, and mounts. | exp: ConfigProfileEditorPanel | dep: ../../icon, ../git/git-mount-editor, ../../../api/config-profiles, ../../../types, ../../../api/tool-types, React, Icon, GitMountEditor, ConfigProfile, CreateConfigProfileRequest, ResolvedProfile, ProjectWithRepos, ToolType
- ConfigProfileListSidebar.tsx | Renders a sidebar component for listing, selecting, creating, and deleting configuration profiles with visual indicators for default status, scope, and includes. | exp: ConfigProfileListSidebar | dep: ../../icon, ../../../api/config-profiles, Icon, ConfigProfile
- ConfigProfilesMobileView.tsx | Renders a mobile-responsive CRUD interface for managing configuration profiles with list, detail, and edit views. | exp: ConfigProfilesMobileView | dep: ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../../icon, ../../../api/config-profiles, MobileListView, MobileDetailView, MobileEditView, MobileFAB, Icon, ConfigProfile, CreateConfigProfileRequest
- ConfigProfilesMobileView.tsx | Renders a mobile-responsive view for managing configuration profiles with list, detail, edit, and preview states. | exp: ConfigProfilesMobileView | dep: react, ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../git/git-mount-editor, ../../icon, ../../../api/config-profiles, ../../../types, ../../../api/tool-types, mobile-list-view, mobile-detail-view, mobile-edit-view, mobile-fab, git-mount-editor, icon, config-profiles API types, types
## arch
Compound component architecture with panel/sidebar split for desktop, dedicated mobile view with state-driven routing, shared state management across list/editor views, and form-based configuration with nested array/object handling for includes, env vars, files, and mounts.
Feature-based component composition with state-driven panels (list/detail/edit/preview), form-based editing, and responsive layout adaptation for mobile/desktop.
## tags
config, mobile, profiles, profile, view, editor, icon, list
mobile, config, view, profiles, editor, profile, list, icon
## symbols
- ConfigProfileEditorPanel
- ConfigProfileListSidebar
@@ -197,7 +197,7 @@ export const ConfigProfileEditorPanel = ({
>
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
<span style={{ fontSize: "0.75rem", padding: "0.125rem 0.375rem", background: "var(--badge-bg, #f3f4f6)", color: "var(--muted)", borderRadius: "0.25rem", textTransform: "uppercase", letterSpacing: "0.025em" }}>{getScopeLabel(profile)}</span>
<span className="badge">{getScopeLabel(profile)}</span>
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
</div>
);
@@ -77,30 +77,19 @@ export const ConfigProfileListSidebar = ({
>
{profile.name}
{profile.is_default && (
<span
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.8,
textTransform: "uppercase",
letterSpacing: "0.025em",
}}
>
<span className="badge badge-secondary" style={{ marginLeft: "0.5rem" }}>
default
</span>
)}
{profile.includes?.length > 0 && (
<span
className="badge"
style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.7,
background:
selectedProfileId === profile.id
? "rgba(255,255,255,0.2)"
: "var(--badge-bg, #f3f4f6)",
padding: "0.0625rem 0.375rem",
borderRadius: "0.25rem",
: undefined,
}}
>
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/mobile
## role
Provides mobile-optimized UI components for core application features including navigation, data views, forms, modals, and specialized terminal interfaces.
Provides a complete set of mobile-optimized UI components for CRUD operations, navigation, and terminal interfaces in a responsive web application.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
@@ -4,22 +4,22 @@ dir: apps/web/src/components/features/mobile
index: apps/web/src/components/features/mobile/.pi-map.index.md
## role
Provides mobile-optimized UI components for core application features including navigation, data views, forms, modals, and specialized terminal interfaces.
Provides a complete set of mobile-optimized UI components for CRUD operations, navigation, and terminal interfaces in a responsive web application.
## files
- mobile-action-sheet.tsx | Renders a mobile-optimized action sheet modal with title, configurable action buttons, and cancel option. | exp: MobileActionSheetItem, func:MobileActionSheet({ isOpen, onClose, title, actions, }: MobileActionSheetProps), call:useRef, call:useEffect, call:onClose, call:document.addEventListener, call:document.removeEventListener, call:e.stopPropagation, call:actions.map, call:action.onClick | dep: react, ../../icon, icon
- mobile-detail-view.tsx | A React component that renders a mobile-optimized detail view with a header (back button, title, edit/delete actions) and a field list supporting multiple value types (text, code, JSON, boolean). | exp: MobileDetailView | dep: ../../icon, react
- mobile-edit-view.tsx | A React component that renders a mobile-optimized form for editing data with configurable field types and save/cancel actions. | exp: MobileEditView | dep: react
- mobile-detail-view.tsx | A React component that renders a mobile-optimized detail view with a header, back/edit/delete actions, and a configurable list of typed fields. | exp: MobileDetailView | dep: ../../icon, React
- mobile-edit-view.tsx | A React component that renders a mobile-optimized form for editing records with configurable field types and save/cancel/delete actions. | exp: MobileEditView | dep: react
- mobile-fab.tsx | Renders a floating action button component for mobile with an add icon and configurable click handler and label. | exp: MobileFAB | dep: ../../icon, react, icon
- mobile-list-view.tsx | Renders a mobile-optimized list view with optional search, empty state, and customizable item rendering | exp: MobileListView | dep: react, ../../icon, ../../../utils/icons, icon
- mobile-nav.tsx | Renders a mobile navigation bar with grouped items that open bottom sheets and standard items that use React Router links, including active state indicators and session count badges. | exp: MobileNav | dep: react, react-router-dom, ../../icon, ../tool/tools-bottom-sheet, ./spaces-bottom-sheet, ../../../utils/icons
- mobile-list-view.tsx | A reusable React component that renders a mobile-optimized list view with optional search, custom item rendering, and action buttons (duplicate/delete). | exp: MobileListView | dep: react, ../../icon, ../../../utils/icons, icon
- mobile-nav.tsx | Renders a mobile navigation bar with grouped items that trigger bottom sheets and standard links with active state highlighting | exp: MobileNav | dep: react, react-router-dom, ../../icon, ../tool/tools-bottom-sheet, ./spaces-bottom-sheet, ../../../utils/icons
- mobile-page-header.tsx | Renders a mobile-only page header with optional back navigation and custom actions. | exp: func:MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps), call:useNavigate, call:useMobileViewport, call:navigate | dep: react-router-dom, ../../../hooks/use-mobile-viewport, ../../icon, use-mobile-viewport hook, Icon component
- mobile-terminal-header.tsx | Renders a mobile-responsive header for a terminal interface with navigation, title, connection status, font size controls, and close actions. | exp: MobileTerminalHeader | dep: react, ../../icon, icon
- mobile-terminal-wrapper.tsx | Wraps a terminal component with mobile-specific UI including auto-hiding header, virtual keyboard handling, and special keys interface. | exp: MobileTerminalWrapper | dep: react, ../terminal/terminal, ./mobile-terminal-header, ../terminal/special-keys-strip, ../terminal/special-keys-panel, ../../../hooks/use-mobile-viewport, ../../../hooks/use-virtual-keyboard, ../../../hooks/use-auto-hide, ../../../hooks/use-special-keys
- spaces-bottom-sheet.tsx | Renders a mobile bottom sheet navigation menu for switching between "Projects" and "Workspaces" spaces with active state highlighting. | exp: SpacesBottomSheet | dep: react-router-dom, ../../icon, icon
## arch
Compositional React component library with feature-specific mobile adaptations, using bottom sheets/action sheets for mobile-native UX patterns, and wrapper components that inject mobile-specific behaviors (auto-hiding headers, virtual keyboard handling) into existing features.
Feature-based component architecture using compound mobile patterns (bottom sheets, action sheets, FABs) with typed configurable props, consistent mobile-first design system, and specialized terminal wrapper with native-like behaviors (auto-hiding headers, virtual keyboard handling).
## tags
mobile, terminal, sheet, react, icon, view, header, renders
mobile, terminal, sheet, react, icon, view, header, action
## symbols
- MobileActionSheet
- MobilePageHeader
@@ -13,6 +13,7 @@ interface MobileDetailViewProps {
onEdit: () => void;
onDelete: () => void;
onBack: () => void;
children?: React.ReactNode;
}
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
@@ -22,6 +23,7 @@ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
onEdit,
onDelete,
onBack,
children,
}) => {
const renderValue = (field: Field) => {
if (field.value === null || field.value === undefined) {
@@ -90,6 +92,7 @@ export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
</div>
))}
</div>
{children}
</div>
);
};
@@ -16,6 +16,8 @@ interface MobileEditViewProps {
fields?: FormField[];
onSave: (data: Record<string, string | number | boolean>) => void;
onCancel: () => void;
onDelete?: () => void;
deleteLabel?: string;
isSaving?: boolean;
children?: React.ReactNode;
}
@@ -25,6 +27,8 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
fields,
onSave,
onCancel,
onDelete,
deleteLabel = "Delete",
isSaving = false,
children,
}) => {
@@ -59,14 +63,7 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
Cancel
</button>
<h1 className="mobile-edit-title">{title}</h1>
<button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
<div style={{ width: "4rem" }} />
</header>
<form className="mobile-edit-form" onSubmit={handleSubmit}>
@@ -156,6 +153,27 @@ export const MobileEditView: React.FC<MobileEditViewProps> = ({
</div>
))}
</form>
<div className="mobile-edit-actions">
<button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
{onDelete && (
<button
className="mobile-edit-delete"
onClick={onDelete}
type="button"
disabled={isSaving}
>
{deleteLabel}
</button>
)}
</div>
</div>
);
};
@@ -24,6 +24,8 @@ interface MobileListViewProps {
export const MobileListView: React.FC<MobileListViewProps> = ({
items,
onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found",
searchPlaceholder = "Search...",
onSearch,
@@ -73,11 +75,38 @@ export const MobileListView: React.FC<MobileListViewProps> = ({
)}
</div>
)}
<div
className="mobile-list-item-actions"
style={{ transform: "rotate(180deg)" }}
>
<Icon name="arrow-left" size="sm" />
<div className="mobile-list-item-actions">
{onItemDuplicate && (
<button
type="button"
className="mobile-list-item-action mobile-list-item-action-duplicate"
onClick={(e) => {
e.stopPropagation();
onItemDuplicate(item.id);
}}
aria-label="Duplicate"
>
<Icon name="copy" size="sm" />
</button>
)}
{onItemDelete && (
<button
type="button"
className="mobile-list-item-action mobile-list-item-action-delete"
onClick={(e) => {
e.stopPropagation();
onItemDelete(item.id);
}}
aria-label="Delete"
>
<Icon name="delete" size="sm" />
</button>
)}
{!onItemDelete && !onItemDuplicate && (
<span style={{ transform: "rotate(180deg)" }}>
<Icon name="arrow-left" size="sm" />
</span>
)}
</div>
</button>
))}
@@ -27,10 +27,17 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
connectionStatus:
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
focusInput: () => void;
changeFontSize: (delta: number) => void;
} | null>(null);
@@ -42,17 +49,33 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
}, [headerAutoHide]);
const handleTerminalReady = useCallback(
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
(
_sessionId: string | undefined,
sendData: (data: string) => void,
connectionStatus:
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalRef({
sendData,
connectionStatus,
focusInput,
changeFontSize,
});
},
[]
[],
);
const handleSendKey = useCallback(
(data: string) => {
terminalRef?.sendData(data);
},
[terminalRef]
[terminalRef],
);
if (!isMobile) {
@@ -0,0 +1,20 @@
# apps/web/src/components/features/profile (index)
dir: apps/web/src/components/features/profile
## role
Provides a mobile-specific UI for users to view and edit their profile information.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
## children
-
## files
- ProfileMobileView.tsx
## links
index: apps/web/src/components/features/profile/.pi-map.index.md
map: apps/web/src/components/features/profile/.pi-map.md
## workflows
- change profile behavior
read: ProfileMobileView.tsx
## dirty
-
@@ -0,0 +1,20 @@
# apps/web/src/components/features/profile
dir: apps/web/src/components/features/profile
index: apps/web/src/components/features/profile/.pi-map.index.md
## role
Provides a mobile-specific UI for users to view and edit their profile information.
## files
- ProfileMobileView.tsx | Renders a mobile-optimized profile editing form with avatar upload, name/email fields, and save/cancel functionality. | exp: ProfileMobileView | dep: react-router-dom, ../mobile/mobile-edit-view, ../../icon, ../../../api/profile
## arch
Single feature-focused component with form handling and file upload, likely using controlled inputs and local state management.
## tags
mobile, profile, view, renders, optimized, editing, form, avatar
## symbols
- ProfileMobileView
## workflows
- change profile behavior
read: ProfileMobileView.tsx
## dirty
-
@@ -0,0 +1,119 @@
import { useNavigate } from "react-router-dom";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { Icon } from "../../icon";
import type { UserProfile } from "../../../api/profile";
interface ProfileMobileViewProps {
profile: UserProfile;
name: string;
email: string;
error: string | null;
isSaving: boolean;
avatarUrl: string | null;
fileInputRef: React.RefObject<HTMLInputElement>;
onNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onAvatarButtonClick: () => void;
onAvatarChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSave: () => void;
}
export const ProfileMobileView = ({
profile,
name,
email,
error,
isSaving,
avatarUrl,
fileInputRef,
onNameChange,
onEmailChange,
onAvatarButtonClick,
onAvatarChange,
onSave,
}: ProfileMobileViewProps) => {
const navigate = useNavigate();
return (
<MobileEditView
title="Profile"
onCancel={() => navigate(-1)}
onSave={onSave}
isSaving={isSaving}
>
<div className="profile-mobile-avatar-section">
<div className="profile-mobile-avatar">
{avatarUrl ? (
<img alt="Avatar" className="profile-mobile-avatar-image" src={avatarUrl} />
) : (
<div className="profile-mobile-avatar-placeholder">
{profile.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<button
className="secondary-button"
disabled={isSaving}
onClick={onAvatarButtonClick}
type="button"
>
{isSaving ? (
<>
<Icon name="loading" size="sm" />
Uploading...
</>
) : (
<>
<Icon name="edit" size="sm" />
Change Avatar
</>
)}
</button>
<input
accept="image/png,image/jpeg"
onChange={onAvatarChange}
ref={fileInputRef}
style={{ display: "none" }}
type="file"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="profile-mobile-name">
Name *
</label>
<input
className="mobile-form-input"
disabled={isSaving}
id="profile-mobile-name"
onChange={(e) => onNameChange(e.target.value)}
placeholder="Your name"
type="text"
value={name}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="profile-mobile-email">
Email *
</label>
<input
className="mobile-form-input"
disabled={isSaving}
id="profile-mobile-email"
onChange={(e) => onEmailChange(e.target.value)}
placeholder="your.email@example.com"
type="email"
value={email}
/>
</div>
{error && (
<p className="mobile-form-error">
<Icon name="warning" size="sm" />
{error}
</p>
)}
</MobileEditView>
);
};
@@ -1,6 +1,9 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
@@ -8,7 +11,9 @@ interface Props {
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
terminalRefs: React.MutableRefObject<
Record<string, React.RefObject<TerminalRef>>
>;
isFullscreen: boolean;
status: string;
error: string | null;
@@ -26,6 +31,7 @@ interface Props {
onHideResetConfirm: () => void;
onReset: () => void;
onTerminalReady: (
sessionId: string | undefined,
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
@@ -64,7 +70,11 @@ export const DesktopTerminalView: React.FC<Props> = ({
>
{!isFullscreen && (
<div className="terminal-page-header">
<button className="secondary-button" onClick={onNavigateBack} type="button">
<button
className="secondary-button"
onClick={onNavigateBack}
type="button"
>
Back
</button>
<h1>Terminal</h1>
@@ -172,21 +182,25 @@ export const DesktopTerminalView: React.FC<Props> = ({
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display: session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
@@ -1,6 +1,9 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
import { Icon } from "../../icon";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
@@ -12,7 +15,9 @@ interface Props {
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
terminalRefs: React.MutableRefObject<
Record<string, React.RefObject<TerminalRef>>
>;
status: string;
error: string | null;
loading: boolean;
@@ -29,6 +34,7 @@ interface Props {
onCreate: () => void;
onRename: (id: string, name: string) => void;
onTerminalReady: (
sessionId: string | undefined,
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
@@ -73,25 +79,53 @@ export const MobileTerminalView: React.FC<Props> = ({
return (
<section className="terminal-page mobile">
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
<div
className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`}
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
<button
className="mobile-terminal-toolbtn"
onClick={onNavigateBack}
type="button"
aria-label="Back"
>
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
<span className="mobile-terminal-title">
{activeSession?.name || "Terminal"}
</span>
<span
className={`mobile-terminal-status status-dot ${status}`}
aria-label={`Connection status: ${status}`}
/>
</div>
<div className="mobile-terminal-toolbar-right">
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
<button
className="mobile-terminal-toolbtn"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
<button
className="mobile-terminal-toolbtn"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
<button
className="mobile-terminal-toolbtn"
onClick={onNavigateBack}
type="button"
aria-label="Exit terminal"
>
<Icon name="close" size="sm" />
</button>
</div>
@@ -115,23 +149,27 @@ export const MobileTerminalView: React.FC<Props> = ({
onClick={onToggleHeader}
>
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display: session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/tool-workshop
## role
Provides a complete UI for managing custom tool types in a "Tool Workshop" interface with list, edit, and mobile-responsive views.
Provides a complete UI for managing custom tool types (compose, dockerfile, manifest) in a workshop interface with list, edit, and mobile-responsive views.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
@@ -4,15 +4,15 @@ dir: apps/web/src/components/features/tool-workshop
index: apps/web/src/components/features/tool-workshop/.pi-map.index.md
## role
Provides a complete UI for managing custom tool types in a "Tool Workshop" interface with list, edit, and mobile-responsive views.
Provides a complete UI for managing custom tool types (compose, dockerfile, manifest) in a workshop interface with list, edit, and mobile-responsive views.
## files
- ToolTypeEditorPanel.tsx | Renders a form panel for creating or editing tool types with support for compose, dockerfile, and manifest definition types | exp: ToolTypeFormState, ToolTypeEditorPanel | dep: ../../icon, ../tool/manifest-editor, ../../../api/tool-types, ../../../api/tool-definitions, React, Icon, ManifestEditor
- ToolTypeListSidebar.tsx | Renders a sidebar component for listing, selecting, creating, and deleting tool types in a "Tool Workshop" interface. | exp: ToolTypeListSidebar | dep: ../../icon, ../../../api/tool-types, React, Icon component, ToolType type
- ToolWorkshopMobileView.tsx | Renders a mobile-responsive view for managing tool types with list, detail, and edit modes | exp: MobileView, ToolWorkshopMobileView | dep: ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../../../api/tool-types, ./ToolTypeEditorPanel, MobileListView, MobileDetailView, MobileEditView, MobileFAB, ToolType, ToolTypeFormState
- ToolWorkshopMobileView.tsx | Renders a mobile-responsive workshop interface for managing tool types with list, detail, and edit views. | exp: MobileView, ToolWorkshopMobileView | dep: react, ../mobile/mobile-list-view, ../mobile/mobile-detail-view, ../mobile/mobile-edit-view, ../mobile/mobile-fab, ../tool/manifest-editor, ../../icon, ../../../api/tool-types, ./ToolTypeEditorPanel, ../../../api/tool-definitions, mobile-list-view, mobile-detail-view, mobile-edit-view, mobile-fab, manifest-editor, icon, tool-types, tool-definitions
## arch
Uses a split-pane sidebar/detail panel pattern with dedicated mobile breakpoint handling, separating list navigation from form editing concerns across three specialized view components.
Feature-based component composition with split-pane layout (sidebar + editor panel), responsive mobile adaptation, and CRUD operations for tool type definitions.
## tags
tool, mobile, type, view, types, list, panel, editor
tool, mobile, view, type, types, editor, list, workshop
## symbols
- ToolTypeFormState
- ToolTypeEditorPanel
@@ -1,9 +1,14 @@
import { useState } from "react";
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import { ManifestEditor } from "../tool/manifest-editor";
import { Icon } from "../../icon";
import type { ToolType } from "../../../api/tool-types";
import type { ToolTypeFormState } from "./ToolTypeEditorPanel";
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
export type MobileView = "list" | "detail" | "edit";
@@ -13,13 +18,18 @@ interface ToolWorkshopMobileViewProps {
mobileView: MobileView;
isCreating: boolean;
toolTypeForm: ToolTypeFormState;
manifestData: Record<string, unknown> | null;
manifestDefinitionId: string | null;
baseDefinitions: ToolDefinitionManifest[];
toolTypeError: string | null;
toolTypeDirty: boolean;
onViewChange: (view: MobileView) => void;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onSubmit: () => void;
onManifestChange: (manifest: Record<string, unknown> | null) => void;
onSubmit: (e?: React.FormEvent) => Promise<boolean>;
onCancel: () => void;
}
@@ -29,15 +39,41 @@ export const ToolWorkshopMobileView = ({
mobileView,
isCreating,
toolTypeForm,
manifestData,
manifestDefinitionId,
baseDefinitions,
toolTypeError,
toolTypeDirty,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onManifestChange,
onSubmit,
onCancel,
}: ToolWorkshopMobileViewProps) => {
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
const ok = await onSubmit();
if (ok) {
onViewChange(isCreating ? "list" : "edit");
}
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (selectedToolType) {
void onDelete(selectedToolType.id);
onViewChange("list");
}
};
if (mobileView === "list") {
return (
<div className="mobile-page">
@@ -49,15 +85,16 @@ export const ToolWorkshopMobileView = ({
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
subtitle: `${t.category || "Uncategorized"} · ${t.definition_type} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
onViewChange("edit");
}
}}
onItemDelete={(id) => onDelete(id)}
emptyMessage="No tool types yet"
/>
<MobileFAB
@@ -71,64 +108,70 @@ export const ToolWorkshopMobileView = ({
}
if (mobileView === "detail" && selectedToolType) {
const fields = [
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean" as const,
},
...(selectedToolType.requires_port
? [{ label: "Default Port", value: selectedToolType.default_port }]
: []),
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
...(selectedToolType.definition_type !== "manifest"
? [
{
label:
selectedToolType.definition_type === "compose"
? "Compose Template"
: "Dockerfile Template",
value:
selectedToolType.definition_type === "compose"
? selectedToolType.compose_template
: selectedToolType.dockerfile_template,
type: "code" as const,
},
]
: []),
];
return (
<MobileDetailView
title={selectedToolType.display_name}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
fields={[
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean",
},
{ label: "Default Port", value: selectedToolType.default_port },
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
{
label: "Compose Template",
value: selectedToolType.compose_template,
type: "code",
},
{
label: "Dockerfile Template",
value: selectedToolType.dockerfile_template,
type: "code",
},
]}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type}`}
fields={fields}
onEdit={() => {
onViewChange("edit");
}}
onDelete={() => {
void onDelete(selectedToolType.id);
onViewChange("list");
}}
onDelete={handleDelete}
onBack={() => {
onViewChange("list");
}}
@@ -137,18 +180,41 @@ export const ToolWorkshopMobileView = ({
}
if (mobileView === "edit") {
const templateValue =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
return (
<MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={onCancel}
onSave={() => {
onSubmit();
if (!toolTypeError) {
onViewChange("list");
}
}}
isSaving={false}
onSave={handleSave}
onDelete={isCreating ? undefined : handleDelete}
deleteLabel="Delete Tool Type"
isSaving={isSaving}
>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
onFormChange({
definition_type: e.target.value as
| "compose"
| "dockerfile"
| "manifest",
})
}
className="mobile-form-select"
disabled={!isCreating}
>
<option value="compose">Docker Compose</option>
<option value="dockerfile">Dockerfile</option>
<option value="manifest">Manifest (Declarative)</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Name *</label>
<input
@@ -157,8 +223,11 @@ export const ToolWorkshopMobileView = ({
onChange={(e) => onFormChange({ name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., my-tool"
disabled={!isCreating}
required
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Display Name *</label>
<input
@@ -167,8 +236,10 @@ export const ToolWorkshopMobileView = ({
onChange={(e) => onFormChange({ display_name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., My Tool"
required
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Description</label>
<textarea
@@ -179,6 +250,7 @@ export const ToolWorkshopMobileView = ({
rows={3}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Category</label>
<input
@@ -189,130 +261,179 @@ export const ToolWorkshopMobileView = ({
placeholder="e.g., development"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Interface Type</label>
<select
value={toolTypeForm.interface_type}
onChange={(e) =>
onChange={(e) => {
const value = e.target.value as "web" | "terminal";
onFormChange({
interface_type: e.target.value as "web" | "terminal",
})
}
interface_type: value,
requires_port: value === "web",
default_port: value === "web" ? toolTypeForm.default_port : "",
});
}}
className="mobile-form-select"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Requires Port</label>
<input
type="checkbox"
checked={toolTypeForm.requires_port}
onChange={(e) => onFormChange({ requires_port: e.target.checked })}
className="mobile-form-checkbox"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port</label>
<input
type="text"
value={toolTypeForm.default_port}
onChange={(e) => onFormChange({ default_port: e.target.value })}
className="mobile-form-input"
placeholder="e.g., 8080"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
onFormChange({
definition_type: e.target.value as "compose" | "dockerfile",
})
}
className="mobile-form-select"
>
<option value="compose">Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => onFormChange({ startup_command: e.target.value })}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
<div className="mobile-form-group mobile-form-row">
<label className="mobile-form-label mobile-form-checkbox-label">
<input
type="checkbox"
checked={toolTypeForm.requires_port}
onChange={(e) =>
onFormChange({ requires_port: e.target.checked })
}
className="mobile-form-checkbox"
/>
Requires Port
</label>
</div>
{toolTypeForm.requires_port && (
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port *</label>
<input
type="number"
value={toolTypeForm.default_port}
onChange={(e) =>
onFormChange({ default_port: e.target.value })
}
className="mobile-form-input"
placeholder="e.g., 8080"
required
/>
</div>
)}
{toolTypeForm.interface_type === "terminal" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) =>
onFormChange({ startup_command: e.target.value })
}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
<small className="mobile-form-help">
Command to run before the interactive shell for each new
terminal session.
</small>
</div>
)}
{toolTypeForm.definition_type === "manifest" ? (
<div className="mobile-manifest-editor-wrapper">
<ManifestEditor
manifest={manifestData}
baseDefinitions={baseDefinitions}
onChange={(m) => onManifestChange(m)}
definitionId={manifestDefinitionId}
/>
</div>
) : (
<div className="mobile-form-group">
<label className="mobile-form-label">
{toolTypeForm.definition_type === "compose"
? "Compose Template *"
: "Dockerfile Template *"}
</label>
<textarea
value={templateValue}
onChange={(e) => {
if (toolTypeForm.definition_type === "compose") {
onFormChange({ compose_template: e.target.value });
} else {
onFormChange({ dockerfile_template: e.target.value });
}
}}
className="mobile-form-textarea mobile-form-code"
placeholder={
toolTypeForm.definition_type === "compose"
? "version: '3'"
: "FROM ubuntu:22.04"
}
rows={10}
required
/>
</div>
)}
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Command</label>
<input
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) => onFormChange({ readiness_command: e.target.value })}
onChange={(e) =>
onFormChange({ readiness_command: e.target.value })
}
className="mobile-form-input"
placeholder="e.g., curl -f http://localhost:8080/health"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Timeout</label>
<label className="mobile-form-label">Readiness Timeout (seconds)</label>
<input
type="text"
type="number"
value={toolTypeForm.readiness_timeout}
onChange={(e) => onFormChange({ readiness_timeout: e.target.value })}
onChange={(e) =>
onFormChange({ readiness_timeout: e.target.value })
}
className="mobile-form-input"
placeholder="30"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Interval</label>
<label className="mobile-form-label">Readiness Interval (seconds)</label>
<input
type="text"
type="number"
value={toolTypeForm.readiness_interval}
onChange={(e) => onFormChange({ readiness_interval: e.target.value })}
onChange={(e) =>
onFormChange({ readiness_interval: e.target.value })
}
className="mobile-form-input"
placeholder="2"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Required Variables</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) => onFormChange({ required_variables: e.target.value })}
onChange={(e) =>
onFormChange({ required_variables: e.target.value })
}
className="mobile-form-input"
placeholder="VAR1, VAR2, VAR3"
/>
</div>
{toolTypeForm.definition_type === "compose" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Compose Template</label>
<textarea
value={toolTypeForm.compose_template}
onChange={(e) => onFormChange({ compose_template: e.target.value })}
className="mobile-form-textarea mobile-form-code"
placeholder="version: '3'"
rows={10}
/>
</div>
{toolTypeError && (
<p className="mobile-form-error">
<Icon name="warning" size="sm" /> {toolTypeError}
</p>
)}
{toolTypeForm.definition_type === "dockerfile" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Dockerfile Template</label>
<textarea
value={toolTypeForm.dockerfile_template}
onChange={(e) =>
onFormChange({ dockerfile_template: e.target.value })
}
className="mobile-form-textarea mobile-form-code"
placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
{toolTypeDirty && (
<button
type="button"
className="secondary-button mobile-discard-button"
onClick={onCancel}
disabled={isSaving}
>
Discard Changes
</button>
)}
</MobileEditView>
);
@@ -328,15 +449,16 @@ export const ToolWorkshopMobileView = ({
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
subtitle: `${t.category || "Uncategorized"} · ${t.definition_type} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
onViewChange("edit");
}
}}
onItemDelete={(id) => onDelete(id)}
emptyMessage="No tool types yet"
/>
<MobileFAB
@@ -342,13 +342,7 @@ export const InstanceList = ({
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
className="checkbox-label checkbox-label-compact"
>
<input
type="checkbox"
@@ -487,13 +481,7 @@ export const InstanceList = ({
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
className="checkbox-label checkbox-label-compact"
>
<input
type="checkbox"
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/hooks
## role
A collection of custom React hooks providing reusable state management, API integration, and UI behavior logic for the web application.
Provides reusable React custom hooks that encapsulate stateful logic, side effects, and API interactions for the web application's UI components, covering data fetching, terminal management, workspace operations, notifications, and mobile-specific behaviors.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
+7 -7
View File
@@ -4,15 +4,15 @@ dir: apps/web/src/hooks
index: apps/web/src/hooks/.pi-map.index.md
## role
A collection of custom React hooks providing reusable state management, API integration, and UI behavior logic for the web application.
Provides reusable React custom hooks that encapsulate stateful logic, side effects, and API interactions for the web application's UI components, covering data fetching, terminal management, workspace operations, notifications, and mobile-specific behaviors.
## files
- use-async-data.ts | A custom React hook that manages asynchronous data fetching with loading, error, and ready states, plus a manual reload capability. | exp: func:useAsyncData(fetcher: () => Promise<T>, deps: React.DependencyList) → UseAsyncDataResult<T>, call:useState, call:useCallback, call:setStatus, call:setError, call:fetcher, call:setData, call:load, call:useEffect | dep: react
- use-auto-hide.ts | A React custom hook that automatically hides an element after a specified timeout and provides manual controls for showing, hiding, and toggling visibility. | exp: func:useAutoHide(options: AutoHideOptions), call:useState, call:useRef, call:Date.now, call:useCallback, call:setIsVisible, call:clearTimeout, call:setTimeout, call:hide, call:show, call:useEffect | dep: react
- use-config-profiles.ts | A React custom hook that manages config profile CRUD operations, form state, profile inclusion with cycle detection, and drag-and-drop reordering. | exp: useConfigProfiles | dep: react, ../utils/errors, ../api/config-profiles, ../api/projects, ../api/tool-types, ../types
- use-events.test.ts | Tests a React custom hook that manages Server-Sent Events connections with automatic reconnection, backoff strategies, and error handling. | dep: vitest, @testing-library/react, ./use-events, ../api/events, use-events hook, ../api/events module
- use-events.ts | React hook that manages a Server-Sent Events connection with exponential backoff reconnection, event buffering, and auth/rate-limit handling. | exp: UseEventsReturn, func:useEvents() → UseEventsReturn, call:useState, call:useRef, call:useCallback, call:clearTimeout, call:createEventSource, call:setConnected, call:setError, call:setReconnectCount, call:JSON.parse, call:setEvents, call:es.close, call:Math.min, call:Math.pow, call:Math.random, call:setTimeout, call:probeEventStreamStatus, call:window.location.assign, call:connect, call:useEffect, call:esRef.current.close | dep: react, ../api/events, ../types/events
- use-config-profiles.ts | A React custom hook that manages CRUD operations, form state, drag-and-drop ordering, and cyclic dependency prevention for configuration profiles. | exp: useConfigProfiles | dep: react, ../utils/errors, ../api/config-profiles, ../api/projects, ../api/tool-types, ../types
- use-events.test.ts | Tests a React hook that manages Server-Sent Events (SSE) connections with automatic reconnection, backoff, and error handling | dep: vitest, @testing-library/react, ./use-events, ../api/events, use-events hook
- use-events.ts | React hook that manages a Server-Sent Events connection with automatic exponential backoff reconnection, lifecycle event handling, and authentication redirect on 401 errors. | exp: UseEventsReturn, func:useEvents() → UseEventsReturn, call:useState, call:useRef, call:useCallback, call:clearTimeout, call:createEventSource, call:setConnected, call:setError, call:setReconnectCount, call:JSON.parse, call:setEvents, call:es.addEventListener, call:es.close, call:Math.min, call:Math.pow, call:Math.random, call:setTimeout, call:probeEventStreamStatus, call:window.location.assign, call:connect, call:useEffect, call:esRef.current.close | dep: react, ../api/events, ../types/events
- use-git-repo.ts | A custom React hook that centralizes all git repository operations (branch management, status tracking, commit history, and git actions) into a reusable interface for components. | exp: GitStatus, UseGitRepoResult, func:useGitRepo(projectId: string | undefined, repoId: string | undefined) → UseGitRepoResult, call:useState, call:useCallback, call:setLoading, call:setError, call:fn, call:extractError, call:withLoading, call:listRepositoryBranches, call:setBranches, call:data.branches.map, call:setDefaultBranch, call:getRepositoryStatus, call:setStatus, call:getRepositoryHistory, call:setHistory, call:getCommitDetail, call:setCommitDetail, call:commitChanges, call:refreshStatus, call:pushRepository, call:pullRepository, call:fetchRepository, call:checkoutBranch, call:refreshBranches, call:createBranch, call:deleteBranch, call:mergeBranches, call:refreshHistory, call:useEffect, raise:err | dep: react, ../api/git-repositories
- use-instance-actions.ts | A custom React hook that provides a centralized interface for managing instance/session actions including opening, starting, stopping, deleting, force-deleting, recreating tunnels, and renaming, with loading states and dirty delete handling for conflict resolution. | exp: func:useInstanceActions(options: UseInstanceActionsOptions) → UseInstanceActionsReturn, call:useSessions, call:useSessionOperations, call:useState, call:useRef, call:useCallback, call:tabRefs.current.get, call:existing.focus, call:session.tool_type_interfaces?.includes, call:window.open, call:tabRefs.current.set, call:setLoadingSessionId, call:startOperation, call:startInstance, call:onRefresh, call:completeOperation, call:stopInstance, call:deleteInstance, call:setDirtyDeleteSession, call:setDirtyDeleteFiles, call:removeSession, call:recreateInstanceTunnel, call:alert, call:newName.trim, call:renameInstance | dep: react, ../api/sessions, ../state/sessions, ../state/session-operations
- use-instance-actions.ts | A React custom hook that manages instance/session actions including opening, starting, stopping, deleting, force-deleting, recreating tunnels, and renaming with loading states and dirty delete handling. | exp: func:useInstanceActions(options: UseInstanceActionsOptions) → UseInstanceActionsReturn, call:useSessions, call:useState, call:useRef, call:useCallback, call:tabRefs.current.get, call:existing.focus, call:session.tool_type_interfaces?.includes, call:window.open, call:tabRefs.current.set, call:setLoadingSessionId, call:startInstance, call:onRefresh, call:stopInstance, call:deleteInstance, call:setDirtyDeleteSession, call:setDirtyDeleteFiles, call:removeSession, call:recreateInstanceTunnel, call:alert, call:newName.trim, call:renameInstance | dep: react, ../api/sessions, ../state/sessions
- use-mobile-viewport.ts | A React hook that tracks whether the viewport width is below a mobile breakpoint (768px) | exp: func:useMobileViewport(), call:useState, call:useEffect, call:setIsMobile, call:window.addEventListener, call:window.removeEventListener | dep: react
- use-notifications.test.tsx | Tests the useNotifications custom React hook with mocked API calls, covering optimistic updates, polling behavior, and error handling. | dep: vitest, @testing-library/react, ./use-notifications, ../state/notifications, ../api/notifications, use-notifications
- use-notifications.ts | Custom React hook that provides access to notification state and ensures it's used within a NotificationProvider | exp: func:useNotifications(), call:useContext, raise:Error | dep: react, ../state/notifications
@@ -23,7 +23,7 @@ A collection of custom React hooks providing reusable state management, API inte
- use-terminal-page.ts | Manages terminal page state including sessions, keyboard shortcuts, fullscreen mode, mobile viewport handling, and terminal lifecycle operations. | exp: useTerminalPage | dep: react, react-router-dom, ../components/features/terminal/terminal, ../components/features/terminal/terminal-session-tabs, ./use-mobile-viewport, ./use-auto-hide, ./use-virtual-keyboard, ./use-terminal-sessions, ../api/terminal, ./use-special-keys, use-mobile-viewport, use-auto-hide, use-virtual-keyboard, use-terminal-sessions, terminal, terminal-session-tabs, api/terminal, api/sessions, use-special-keys
- use-terminal-sessions.ts | React custom hook that manages terminal session state (CRUD operations, active session tracking) for a given instance | exp: UseTerminalSessionsResult, func:useTerminalSessions(instanceId: string) → UseTerminalSessionsResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listTerminalSessions, call:setSessions, call:setActiveSessionId, call:createTerminalSession, call:closeTerminalSession, call:prev.filter, call:renameTerminalSession, call:prev.map, call:resetTerminalSession, call:loadSessions, call:useEffect | dep: react, ../api/terminal
- use-theme.ts | React hook that fetches user theme preference on mount and applies it to the document root element via data-theme attribute | exp: func:useTheme(), call:useEffect, call:getUserConfig, call:document.documentElement.removeAttribute, call:document.documentElement.setAttribute | dep: react, ../api/settings
- use-tool-workshop.ts | React custom hook that manages state and operations for a tool workshop UI, including CRUD operations for tool types and tool definitions with form handling and validation. | exp: useToolWorkshop | dep: react, ../utils/errors, ../api/tool-types, ../api/tool-definitions, ../components/features/tool-workshop/ToolTypeEditorPanel
- use-tool-workshop.ts | Custom React hook that manages state and operations for a tool workshop UI, including CRUD operations for tool types and tool definitions with form handling and validation. | exp: useToolWorkshop | dep: react, ../utils/errors, ../api/tool-types, ../api/tool-definitions, ../components/features/tool-workshop/ToolTypeEditorPanel
- use-virtual-keyboard.ts | React hook that detects virtual keyboard open/close state and measures its height on mobile devices | exp: func:useVirtualKeyboard(), call:useState, call:useCallback, call:setState, call:useEffect, call:visualViewport.addEventListener, call:window.addEventListener, call:updateKeyboardState, call:visualViewport.removeEventListener, call:window.removeEventListener | dep: react
- use-workspace-actions.ts | Provides a React hook that encapsulates workspace CRUD operations with loading state management and user confirmation dialogs for destructive actions. | exp: UseWorkspaceActionsResult, func:useWorkspaceActions() → UseWorkspaceActionsResult, call:useState, call:useCallback, call:createWorkspace, call:setLoadingId, call:deleteWorkspace, call:onRefresh, call:window.confirm, call:instances.map((i) => `- ${i.name}`).join, call:syncWorkspace, call:updateWorkspace, raise:err | dep: react, ../api/workspaces, ../types/workspace
- use-workspace-files.ts | Custom React hook for managing workspace file operations including listing, loading, saving, and navigating files. | exp: UseWorkspaceFilesResult, func:useWorkspaceFiles(workspaceId: string) → UseWorkspaceFilesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaceFiles, call:setEntries, call:setCurrentPath, call:setContent, call:getWorkspaceFileContent, call:saveWorkspaceFile, call:refresh, call:useEffect | dep: react, ../api/workspace-files
@@ -31,7 +31,7 @@ A collection of custom React hooks providing reusable state management, API inte
- use-workspace-instances.ts | Custom React hook for managing workspace instances with CRUD operations, loading states, and error handling. | exp: UseWorkspaceInstancesResult, func:useWorkspaceInstances(workspaceId: string) → UseWorkspaceInstancesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaceInstances, call:setInstances, call:createWorkspaceInstance, call:refresh, call:useEffect | dep: react, ../api/workspace-instances, ../api/sessions
- use-workspaces.ts | Custom React hook that fetches and manages workspace data with loading and error states. | exp: UseWorkspacesResult, func:useWorkspaces(projectId: string, repoId: string) → UseWorkspacesResult, call:useState, call:useCallback, call:setLoading, call:setError, call:listWorkspaces, call:listAllWorkspaces, call:setWorkspaces, call:useEffect, call:refresh | dep: react, ../api/workspaces, ../types/workspace
## arch
Follows a feature-based composition pattern where each hook encapsulates a specific domain concern (data fetching, CRUD operations, terminal/session management, UI interactions), often combining React state with API calls, side effects, and provider context integration.
Follows a custom hook composition pattern where each hook encapsulates a single domain concern (data fetching, CRUD operations, UI state, device detection), often combining useState/useEffect/useCallback with SWR-style async data management, and layering higher-level hooks (use-terminal-page) from lower-level ones (use-mobile-viewport, use-virtual-keyboard).
## tags
call:set, call:use, workspace, react, state, terminal, api, callback
## symbols
+4 -2
View File
@@ -193,7 +193,7 @@ export const useConfigProfiles = () => {
setDragOverIndex(null);
};
const handleSubmit = async (e?: React.FormEvent) => {
const handleSubmit = async (e?: React.FormEvent): Promise<boolean> => {
e?.preventDefault();
setError(null);
setSaveStatus("saving");
@@ -201,7 +201,7 @@ export const useConfigProfiles = () => {
if (!formData.name?.trim()) {
setError("Name is required");
setSaveStatus("error");
return;
return false;
}
try {
@@ -224,9 +224,11 @@ export const useConfigProfiles = () => {
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
if (refreshed) populateForm(refreshed);
}
return true;
} catch (err) {
setError(extractErrorMessage(err));
setSaveStatus("error");
return false;
}
};
+328 -305
View File
@@ -10,336 +10,359 @@ import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "./use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
export const useTerminalPage = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const sendDataRef = useRef<((data: string) => void) | null>(null);
const focusInputRef = useRef<(() => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRefs = useRef<
Record<string, ((delta: number) => void) | null>
>({});
const sendDataRefs = useRef<Record<string, ((data: string) => void) | null>>(
{},
);
const focusInputRefs = useRef<Record<string, (() => void) | null>>({});
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [instanceInfo, setInstanceInfo] = useState<{
display_name: string;
workspace_name?: string | null;
tool_type_name: string;
} | null>(null);
const [instanceInfo, setInstanceInfo] = useState<{
display_name: string;
workspace_name?: string | null;
tool_type_name: string;
} | null>(null);
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
// Fetch instance details for tab title
useEffect(() => {
if (!instanceId) return;
const load = async () => {
try {
const { getUserSessions } = await import("../api/sessions");
const allSessions = await getUserSessions();
const match = allSessions.find((s) => s.id === instanceId);
if (match) {
setInstanceInfo({
display_name: match.display_name,
workspace_name: match.workspace_name,
tool_type_name: match.tool_type_name,
});
}
} catch {
// ignore
}
};
void load();
}, [instanceId]);
// Fetch instance details for tab title
useEffect(() => {
if (!instanceId) return;
const load = async () => {
try {
const { getUserSessions } = await import("../api/sessions");
const allSessions = await getUserSessions();
const match = allSessions.find((s) => s.id === instanceId);
if (match) {
setInstanceInfo({
display_name: match.display_name,
workspace_name: match.workspace_name,
tool_type_name: match.tool_type_name,
});
}
} catch {
// ignore
}
};
void load();
}, [instanceId]);
// Auto-create default session
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
// Auto-create default session
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
// Update document title based on active terminal session
useEffect(() => {
if (!instanceId) {
document.title = "Terminal";
return;
}
const active = sessions.find((s) => s.id === activeSessionId);
const baseName = instanceInfo
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
: `Instance ${instanceId.slice(0, 8)}`;
if (sessions.length <= 1) {
document.title = baseName;
} else {
const sessionName = active?.name ?? "Session";
document.title = `${baseName} ${sessionName}`;
}
return () => {
document.title = "Headquarter";
};
}, [instanceId, activeSessionId, sessions, instanceInfo]);
// Update document title based on active terminal session
useEffect(() => {
if (!instanceId) {
document.title = "Terminal";
return;
}
const active = sessions.find((s) => s.id === activeSessionId);
const baseName = instanceInfo
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
: `Instance ${instanceId.slice(0, 8)}`;
if (sessions.length <= 1) {
document.title = baseName;
} else {
const sessionName = active?.name ?? "Session";
document.title = `${baseName} ${sessionName}`;
}
return () => {
document.title = "Headquarter";
};
}, [instanceId, activeSessionId, sessions, instanceInfo]);
// Sync refs with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Sync refs with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
delete sendDataRefs.current[id];
delete focusInputRefs.current[id];
delete changeFontSizeRefs.current[id];
}
}
setTerminalStatuses((prev) => {
const next: Record<string, TerminalStatus> = {};
for (const id of currentIds) {
if (prev[id]) {
next[id] = prev[id];
}
}
return next;
});
}, [sessions]);
// Fit and focus active terminal
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Fit and focus active terminal
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1)
setActiveSessionId(sessions[idx + 1].id);
}
break;
case "r":
e.preventDefault();
if (activeSessionId) void resetSession(activeSessionId);
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
}
};
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1)
setActiveSessionId(sessions[idx + 1].id);
}
break;
case "r":
e.preventDefault();
if (activeSessionId) void resetSession(activeSessionId);
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Keep screen awake
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void requestWakeLock();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Keep screen awake
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void requestWakeLock();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
// Lock page scroll on mobile
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) return;
setIsFullscreen(false);
},
[isFullscreen],
);
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) return;
setIsFullscreen(false);
},
[isFullscreen],
);
const handleSelect = useCallback(
(sessionId: string) => setActiveSessionId(sessionId),
[setActiveSessionId],
);
const handleSelect = useCallback(
(sessionId: string) => setActiveSessionId(sessionId),
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => closeSession(sessionId),
[closeSession],
);
const handleClose = useCallback(
async (sessionId: string) => closeSession(sessionId),
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleTerminalReady = useCallback(
(
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({
...prev,
[activeSessionId ?? "default"]: status,
}));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
);
const handleTerminalReady = useCallback(
(
sessionId: string | undefined,
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
const key = sessionId ?? "default";
setTerminalStatuses((prev) => ({
...prev,
[key]: status,
}));
sendDataRefs.current[key] = sendData;
focusInputRefs.current[key] = focusInput;
changeFontSizeRefs.current[key] = changeFontSize;
},
[],
);
const handleFontSizeChange = useCallback((delta: number) => {
changeFontSizeRef.current?.(delta);
}, []);
const handleFontSizeChange = useCallback(
(delta: number) => {
const key = activeSessionId ?? "default";
changeFontSizeRefs.current[key]?.(delta);
},
[activeSessionId],
);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleSendKey = useCallback(
(data: string) => {
const key = activeSessionId ?? "default";
sendDataRefs.current[key]?.(data);
},
[activeSessionId],
);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
return {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
sendDataRef,
focusInputRef,
changeFontSizeRef,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
setActiveSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos: SESSIONS_TO_INFO(sessions),
};
return {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
setActiveSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos: SESSIONS_TO_INFO(sessions),
};
};
+7 -5
View File
@@ -151,13 +151,13 @@ export const useToolWorkshop = () => {
resetToolTypeForm();
};
const handleToolTypeSubmit = async (e?: React.FormEvent) => {
const handleToolTypeSubmit = async (e?: React.FormEvent): Promise<boolean> => {
e?.preventDefault();
setToolTypeError(null);
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
setToolTypeError("Name and display name are required");
return;
return false;
}
if (
@@ -166,7 +166,7 @@ export const useToolWorkshop = () => {
isNaN(Number(toolTypeForm.default_port)))
) {
setToolTypeError("Default port is required and must be a number");
return;
return false;
}
if (toolTypeForm.definition_type !== "manifest") {
@@ -179,13 +179,13 @@ export const useToolWorkshop = () => {
setToolTypeError(
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
);
return;
return false;
}
} else if (!manifestData) {
setToolTypeError(
"Manifest data is required for manifest definition type",
);
return;
return false;
}
const variables = toolTypeForm.required_variables
@@ -307,8 +307,10 @@ export const useToolWorkshop = () => {
setToolTypeDirty(false);
}
await loadData();
return true;
} catch (err) {
setToolTypeError(extractErrorMessage(err));
return false;
}
};
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/pages
## role
Contains top-level page components that serve as route endpoints for the web application's primary UI surfaces, each handling a specific domain area (dashboard, projects, workspaces, git, settings, etc.) with responsive layouts and CRUD operations.
Contains top-level page components that render the main UI views for the web application, each corresponding to a distinct route and feature area.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md
+8 -8
View File
@@ -4,30 +4,30 @@ dir: apps/web/src/pages
index: apps/web/src/pages/.pi-map.index.md
## role
Contains top-level page components that serve as route endpoints for the web application's primary UI surfaces, each handling a specific domain area (dashboard, projects, workspaces, git, settings, etc.) with responsive layouts and CRUD operations.
Contains top-level page components that render the main UI views for the web application, each corresponding to a distinct route and feature area.
## files
- ConfigProfilesPage.tsx | Renders a responsive configuration profiles management page with sidebar list and editor panel for desktop, and a dedicated mobile view for creating, editing, and managing config profiles. | exp: ConfigProfilesPage | dep: react, ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-config-profiles, ../components/features/config-profiles/ConfigProfileListSidebar, ../components/features/config-profiles/ConfigProfileEditorPanel, ../components/features/config-profiles/ConfigProfilesMobileView
- ConfigProfilesPage.tsx | Renders a configuration profiles management page with responsive desktop/mobile layouts for creating, editing, previewing, and organizing config profiles. | exp: ConfigProfilesPage | dep: react, ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-config-profiles, ../components/features/config-profiles/ConfigProfileListSidebar, ../components/features/config-profiles/ConfigProfileEditorPanel, ../components/features/config-profiles/ConfigProfilesMobileView, use-mobile-viewport, use-config-profiles, data-states, ConfigProfileListSidebar, ConfigProfileEditorPanel, ConfigProfilesMobileView
- DashboardPage.test.tsx | Unit tests for the DashboardPage component verifying overview loading and error retry behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./DashboardPage, ../state/sessions, DashboardPage, SessionsProvider
- DashboardPage.tsx | Renders a dashboard homepage that displays workspace overview, active/recent sessions, summary statistics, and polling health checks for running instances. | exp: HomePage | dep: react, react-router-dom, ../api/dashboard, ../api/sessions, ../components/data-states, ../components/features/session/session-list, ../hooks/use-instance-actions, ../state/sessions
- GitHistoryPage.tsx | Renders a Git commit history page with branch selection, commit list with graph visualization, and a detail panel showing commit metadata, stats, and diffs. | exp: GitHistoryPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../hooks/use-async-data
- GitRepositoriesPage.tsx | Displays and manages a project's Git repositories with CRUD operations including listing, creating, navigating to history, and deleting with confirmation | exp: GitRepositoriesPage | dep: react, react-router-dom, ../api/git-repositories, ../components/data-states, ../components/icon, ../components/features/project/repository-create-dialog, ../hooks/use-async-data
- PlaceholderPage.tsx | Exports three simple React page components (PlaceholderPage, NotFoundPage, LoginRedirectPage) for a frontend scaffold. | exp: PlaceholderPage, NotFoundPage, LoginRedirectPage | dep: ../components/icon, React
- ProfilePage.tsx | A React component that displays and allows editing of a user profile, including name, email, and avatar upload with validation. | exp: ProfilePage | dep: react, ../api/profile, ../components/data-states, ../components/icon, ../state/auth, ../hooks/use-async-data
- ProfilePage.tsx | A React component that renders a responsive profile page with form editing, avatar upload, and save functionality for both mobile and desktop viewports. | exp: ProfilePage | dep: react, ../api/profile, ../components/data-states, ../components/icon, ../components/features/profile/ProfileMobileView, ../state/auth, ../hooks/use-async-data, ../hooks/use-mobile-viewport
- ProjectSettingsPage.tsx | Renders a project settings page with tabbed navigation for general settings, repositories, and members, including project data fetching, editing, and deletion capabilities. | exp: ProjectSettingsPage | dep: react, react-router-dom, ../components/features/settings/settings-tab-layout, ../components/features/project/repositories-settings-tab, ../api/client, ../types
- ProjectsPage.test.tsx | Unit tests for the ProjectsPage component covering loading, empty, error, create, edit, and delete states with API mocking. | dep: @testing-library/react, react-router-dom, vitest, ./ProjectsPage, ../api/projects, ProjectsPage
- ProjectsPage.tsx | Renders a responsive projects management page with separate mobile and desktop layouts, supporting project CRUD operations, repository management, and workspace actions. | exp: ProjectsPage | dep: react, ../components/data-states, ../components/icon, ../hooks/use-mobile-viewport, ../components/features/project/ProjectCard, ../components/features/project/ProjectDialog, ../components/features/project/repository-create-dialog, ../components/features/mobile/mobile-list-view, ../components/features/mobile/mobile-fab, ../hooks/use-projects, ../types
- ProjectsPage.tsx | Renders a responsive projects management page with separate mobile and desktop layouts, supporting project CRUD operations, repository management, and workspace actions. | exp: ProjectsPage | dep: react, ../components/data-states, ../components/icon, ../hooks/use-mobile-viewport, ../components/features/project/ProjectCard, ../components/features/project/ProjectDialog, ../components/features/project/repository-create-dialog, ../components/features/mobile/mobile-list-view, ../components/features/mobile/mobile-fab, ../hooks/use-projects, ../types, use-mobile-viewport, use-projects, data-states, icon, ProjectCard, ProjectDialog, RepositoryCreateDialog, MobileListView, MobileFAB
- SessionsPage.tsx | Renders a sessions management page that displays, polls health for, and handles CRUD operations on development environment sessions with dirty delete confirmation. | exp: SessionsPage | dep: react, ../api/sessions, ../api/settings, ../components/data-states, ../components/features/session/session-list, ../components/features/session/session-card, ../hooks/use-instance-actions, ../state/sessions
- SettingsPage.tsx | A React settings page component that loads, displays, and manages user configuration with tabbed navigation and nested outlet for child routes. | exp: SettingsPage | dep: react, react-router-dom, ../api/settings, ../components/data-states, ../hooks/use-async-data, ../components/features/settings/GeneralSettingsTab
- SshKeysPage.tsx | React page component for managing SSH keys including generation, listing, signing, verification, and deletion | exp: SSHKeysPage | dep: react-router-dom, ../components/data-states, ../hooks/use-ssh-keys, ../components/features/ssh-keys/SSHKeyCreateForm, ../components/features/ssh-keys/SSHKeyList
- TerminalPage.tsx | Renders a responsive terminal page that switches between mobile and desktop views based on device type, managing terminal sessions and their interactions. | exp: TerminalPage | dep: react, ../hooks/use-terminal-page, ../components/features/terminal/MobileTerminalView, ../components/features/terminal/DesktopTerminalView, useTerminalPage hook, MobileTerminalView, DesktopTerminalView
- ToolWorkshopPage.tsx | Renders a responsive tool workshop page with sidebar/editor layout for desktop and tabbed mobile view for managing tool types | exp: ToolWorkshopPage | dep: ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-tool-workshop, ../components/features/tool-workshop/ToolTypeListSidebar, ../components/features/tool-workshop/ToolTypeEditorPanel, ../components/features/tool-workshop/ToolWorkshopMobileView, react, use-mobile-viewport, use-tool-workshop, data-states, ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView
- ToolWorkshopPage.tsx | Renders a responsive tool workshop page with list and editor views for managing tool types, handling loading/error states and mobile/desktop layouts | exp: ToolWorkshopPage | dep: ../components/data-states, ../hooks/use-mobile-viewport, ../hooks/use-tool-workshop, ../components/features/tool-workshop/ToolTypeListSidebar, ../components/features/tool-workshop/ToolTypeEditorPanel, ../components/features/tool-workshop/ToolWorkshopMobileView, react, use-mobile-viewport, use-tool-workshop, data-states, ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView
- WorkspaceDetailPage.test.tsx | Tests the WorkspaceDetailPage component rendering and tab switching behavior | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./WorkspaceDetailPage, @testing-library/jest-dom, WorkspaceDetailPage, use-workspaces, use-workspace-files, use-workspace-git, use-workspace-instances, use-mobile-viewport
- WorkspaceDetailPage.tsx | Renders a workspace detail page with tab-based navigation for files, git, tools, and settings panels, with mobile-responsive layout. | exp: func:WorkspaceDetailPage(), call:useParams, call:useState, call:useMobileViewport, call:useWorkspaces, call:workspaces.find | dep: react, react-router-dom, ../hooks/use-workspaces, ../hooks/use-mobile-viewport, ../components/features/workspace/workspace-detail-header, ../components/features/workspace/workspace-tab-bar, ../components/features/workspace/workspace-file-panel, ../components/features/workspace/workspace-git-panel, ../components/features/workspace/workspace-tools-panel, ../components/features/workspace/workspace-settings-panel, use-workspaces, use-mobile-viewport, workspace-detail-header, workspace-tab-bar, workspace-file-panel, workspace-git-panel, workspace-tools-panel, workspace-settings-panel
- WorkspacesPage.tsx | Renders a responsive workspaces management page with separate mobile and desktop layouts, supporting workspace listing, creation, deletion, sync, and tool launching. | exp: func:WorkspacesPage(), call:useMobileViewport, call:useState, call:useWorkspaces, call:useWorkspaceActions, call:actions.delete, call:actions.sync, call:setMobileView, call:refresh, call:setStartWorkspace, call:handleDelete, call:setSelectedWorkspace, call:workspaces.map, call:e.stopPropagation, call:setShowCreate | dep: react, ../components/icon, ../hooks/use-mobile-viewport, ../hooks/use-workspaces, ../hooks/use-workspace-actions, ../components/features/workspace/workspace-card, ../components/features/workspace/workspace-create-form, ../components/features/mobile/mobile-detail-view, ../components/features/mobile/mobile-fab, ../components/features/tool/tool-starter, ../types/workspace
- WorkspacesPage.tsx | Renders a responsive workspaces management page with mobile and desktop views supporting listing, creating, viewing details, syncing, deleting, and starting tools for workspaces. | exp: func:WorkspacesPage(), call:useMobileViewport, call:useState, call:useWorkspaces, call:useWorkspaceActions, call:actions.delete, call:actions.sync, call:setMobileView, call:refresh, call:setStartWorkspace, call:handleDelete, call:setSelectedWorkspace, call:workspaces.map, call:e.stopPropagation, call:setShowCreate | dep: react, ../components/icon, ../hooks/use-mobile-viewport, ../hooks/use-workspaces, ../hooks/use-workspace-actions, ../components/features/workspace/workspace-card, ../components/features/workspace/workspace-create-form, ../components/features/mobile/mobile-detail-view, ../components/features/mobile/mobile-fab, ../components/features/tool/tool-starter, ../types/workspace
## arch
Follows a page-based routing architecture where each file maps to a URL route, using responsive design patterns with explicit mobile/desktop view branching, compound component layouts (sidebar/editor, tabbed panels), polling for real-time data, and direct API integration within page components rather than abstracted service layers.
Route-based page organization with responsive mobile/desktop layout patterns, tabbed navigation for complex pages, polling/health-check mechanisms for live data, and CRUD operations with loading/error/empty states throughout.
## tags
page, components, workspace, features, react, mobile, settings, hooks
page, components, workspace, features, mobile, react, hooks, settings
## symbols
- WorkspaceDetailPage
- WorkspacesPage
+25 -1
View File
@@ -6,7 +6,7 @@ import { ConfigProfileListSidebar } from "../components/features/config-profiles
import { ConfigProfileEditorPanel } from "../components/features/config-profiles/ConfigProfileEditorPanel";
import { ConfigProfilesMobileView } from "../components/features/config-profiles/ConfigProfilesMobileView";
type MobileView = "list" | "detail" | "edit";
type MobileView = "list" | "detail" | "edit" | "preview";
export const ConfigProfilesPage = () => {
const isMobile = useMobileViewport();
@@ -100,11 +100,18 @@ export const ConfigProfilesPage = () => {
return (
<ConfigProfilesMobileView
profiles={profiles}
projects={projects}
toolTypes={toolTypes}
selectedProfile={selectedProfile}
mobileView={mobileView}
isCreating={isCreating}
formData={formData}
includedProfileIds={includedProfileIds}
availableProfiles={availableProfilesForInclude()}
previewData={previewData}
previewingId={previewingId}
saveStatus={saveStatus}
error={error}
onViewChange={setMobileView}
onSelect={handleSelectProfile}
onCreate={handleCreateNew}
@@ -112,6 +119,23 @@ export const ConfigProfilesPage = () => {
onFormChange={updateFormField}
onSubmit={handleSubmit}
getScopeLabel={getScopeLabel}
getIncludedProfile={getIncludedProfile}
onAddInclude={addInclude}
onRemoveInclude={removeInclude}
onAddEnvVar={addEnvVar}
onUpdateEnvVar={updateEnvVar}
onRemoveEnvVar={removeEnvVar}
onAddFile={addFile}
onUpdateFile={updateFile}
onRemoveFile={removeFile}
onAddMount={addMount}
onUpdateMount={updateMount}
onRemoveMount={removeMount}
onAddMountFile={addMountFile}
onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile}
onPreview={handlePreview}
onClosePreview={() => setPreviewData(null)}
/>
);
}
+22 -3
View File
@@ -3,13 +3,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { ProfileMobileView } from "../components/features/profile/ProfileMobileView";
import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import type { UserProfile } from "../api/profile";
type ProfileStatus = "loading" | "ready" | "error" | "saving";
export const ProfilePage = () => {
const isMobile = useMobileViewport();
const { refreshSession } = useAuth();
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
@@ -90,14 +93,30 @@ export const ProfilePage = () => {
return (
<section className="stack">
<h1>Profile</h1>
{!isMobile && <h1>Profile</h1>}
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
<div className="card stack">
isMobile ? (
<ProfileMobileView
profile={profile}
name={name}
email={email}
error={error}
isSaving={displayStatus === "saving"}
avatarUrl={avatarUrl}
fileInputRef={fileInputRef}
onNameChange={setName}
onEmailChange={setEmail}
onAvatarButtonClick={() => fileInputRef.current?.click()}
onAvatarChange={handleAvatarChange}
onSave={() => void handleSave()}
/>
) : (
<div className="card stack">
<div className="profile-avatar-section">
<div className="avatar-preview">
{avatarUrl ? (
@@ -178,7 +197,7 @@ export const ProfilePage = () => {
</button>
</div>
</div>
)}
))}
</section>
);
};
+9 -1
View File
@@ -57,12 +57,20 @@ export const ToolWorkshopPage = () => {
mobileView={mobileView}
isCreating={isCreating}
toolTypeForm={toolTypeForm}
manifestData={manifestData}
manifestDefinitionId={manifestDefinitionId}
baseDefinitions={baseDefinitions}
toolTypeError={toolTypeError}
toolTypeDirty={toolTypeDirty}
onViewChange={setMobileView}
onSelect={handleSelectToolType}
onCreate={handleCreateNew}
onDelete={handleDeleteToolType}
onFormChange={handleFormChange}
onManifestChange={(m) => {
setManifestData(m);
setToolTypeDirty(true);
}}
onSubmit={handleToolTypeSubmit}
onCancel={() => {
if (toolTypeDirty) {
@@ -70,7 +78,7 @@ export const ToolWorkshopPage = () => {
return;
}
}
setMobileView(isCreating ? "list" : "detail");
setMobileView("list");
}}
/>
);
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/styles
## role
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, design tokens, utility classes, and component-specific styles.
Provides the complete visual design system and styling foundation for the web application, encompassing global styles, theme tokens, utility classes, and component-specific styles.
## parent
index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md

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