From e4c5e7f2dbcfc8663733f774baab26c894ddf2d8 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 22 May 2026 20:57:30 +0200 Subject: [PATCH 1/6] chore: archive tool-workshop OpenSpec change - Update tasks.md to mark all 140 tasks as complete - Archive tool-workshop change to openspec/changes/archive/2026-05-22-tool-workshop/ --- .../2026-05-22-tool-workshop}/design.md | 0 .../2026-05-22-tool-workshop}/proposal.md | 0 .../specs/config-folders.md | 0 .../specs/readiness-probes.md | 0 .../specs/tool-workshop.md | 0 .../archive/2026-05-22-tool-workshop/tasks.md | 204 ++++++++++++++++++ openspec/changes/tool-workshop/tasks.md | 204 ------------------ 7 files changed, 204 insertions(+), 204 deletions(-) rename openspec/changes/{tool-workshop => archive/2026-05-22-tool-workshop}/design.md (100%) rename openspec/changes/{tool-workshop => archive/2026-05-22-tool-workshop}/proposal.md (100%) rename openspec/changes/{tool-workshop => archive/2026-05-22-tool-workshop}/specs/config-folders.md (100%) rename openspec/changes/{tool-workshop => archive/2026-05-22-tool-workshop}/specs/readiness-probes.md (100%) rename openspec/changes/{tool-workshop => archive/2026-05-22-tool-workshop}/specs/tool-workshop.md (100%) create mode 100644 openspec/changes/archive/2026-05-22-tool-workshop/tasks.md delete mode 100644 openspec/changes/tool-workshop/tasks.md diff --git a/openspec/changes/tool-workshop/design.md b/openspec/changes/archive/2026-05-22-tool-workshop/design.md similarity index 100% rename from openspec/changes/tool-workshop/design.md rename to openspec/changes/archive/2026-05-22-tool-workshop/design.md diff --git a/openspec/changes/tool-workshop/proposal.md b/openspec/changes/archive/2026-05-22-tool-workshop/proposal.md similarity index 100% rename from openspec/changes/tool-workshop/proposal.md rename to openspec/changes/archive/2026-05-22-tool-workshop/proposal.md diff --git a/openspec/changes/tool-workshop/specs/config-folders.md b/openspec/changes/archive/2026-05-22-tool-workshop/specs/config-folders.md similarity index 100% rename from openspec/changes/tool-workshop/specs/config-folders.md rename to openspec/changes/archive/2026-05-22-tool-workshop/specs/config-folders.md diff --git a/openspec/changes/tool-workshop/specs/readiness-probes.md b/openspec/changes/archive/2026-05-22-tool-workshop/specs/readiness-probes.md similarity index 100% rename from openspec/changes/tool-workshop/specs/readiness-probes.md rename to openspec/changes/archive/2026-05-22-tool-workshop/specs/readiness-probes.md diff --git a/openspec/changes/tool-workshop/specs/tool-workshop.md b/openspec/changes/archive/2026-05-22-tool-workshop/specs/tool-workshop.md similarity index 100% rename from openspec/changes/tool-workshop/specs/tool-workshop.md rename to openspec/changes/archive/2026-05-22-tool-workshop/specs/tool-workshop.md diff --git a/openspec/changes/archive/2026-05-22-tool-workshop/tasks.md b/openspec/changes/archive/2026-05-22-tool-workshop/tasks.md new file mode 100644 index 0000000..868d616 --- /dev/null +++ b/openspec/changes/archive/2026-05-22-tool-workshop/tasks.md @@ -0,0 +1,204 @@ +## Phase 1: Backend Foundation + +### 1.1 Database Migrations +- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe) +- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes) +- [x] 1.1.3 Create Alembic migration for `config_folders` (new table) +- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range) +- [x] 1.1.5 Add indexes for config_folders +- [x] 1.1.6 Run migrations locally and verify with test data + +### 1.2 Model Updates +- [x] 1.2.1 Update `ToolType` model with new fields +- [x] 1.2.2 Update `ToolConfig` model with new fields +- [x] 1.2.3 Create `ConfigFolder` model +- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response) +- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields) +- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields) +- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum) + +### 1.3 Config Folder API +- [x] 1.3.1 Create `api/config_folders.py` router +- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project) +- [x] 1.3.3 Implement `POST /config-folders` (create) +- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files) +- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete) +- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override) +- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override) +- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override) +- [x] 1.3.9 Add validation: 10MB size limit per folder +- [x] 1.3.10 Add ownership checks (user can only access own folders) + +### 1.4 Tool Type API Updates +- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe +- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields +- [x] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation) +- [x] 1.4.4 Update tool type response schemas +- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types + +### 1.5 Tool Config API Updates +- [x] 1.5.1 Update `POST /tool-configs` to accept new fields +- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields +- [x] 1.5.3 Update `GET /tool-configs` to return new fields +- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint +- [x] 1.5.5 Add validation for port_override range +- [x] 1.5.6 Add validation for environment_variables JSON structure +- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type) + +## Phase 2: Instance Creation Enhancement + +### 2.1 Docker Build Service +- [x] 2.1.1 Create `services/docker_build.py` for Dockerfile builds +- [x] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function +- [x] 2.1.3 Handle build context file writing +- [x] 2.1.4 Add build output streaming/logging +- [x] 2.1.5 Handle build failures with clear error messages + +### 2.2 Compose Generation for Dockerfile Tools +- [x] 2.2.1 Create compose template for dockerfile-built images +- [x] 2.2.2 Integrate build service into instance creation flow +- [x] 2.2.3 Update `render_compose_template` to handle both paths + +### 2.3 Config Folder Mounting +- [x] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function +- [x] 2.3.2 Resolve config folders for user + project +- [x] 2.3.3 Generate volume mounts in compose file for config folders +- [x] 2.3.4 Apply project overrides during resolution +- [x] 2.3.5 Write config folder files to `instance_dir/volumes/` + +### 2.4 Readiness Probe Service +- [x] 2.4.1 Create `services/readiness_probe.py` +- [x] 2.4.2 Implement `execute_probe(container_id, probe_config)` function +- [x] 2.4.3 Implement polling loop with timeout and interval +- [x] 2.4.4 Store probe output/logs on instance +- [x] 2.4.5 Update instance status based on probe result ("running" or "failed") +- [x] 2.4.6 Handle probe command failures gracefully + +### 2.5 Instance Creation Integration +- [x] 2.5.1 Update `create_instance` endpoint to use new fields +- [x] 2.5.2 Integrate dockerfile build path into creation flow +- [x] 2.5.3 Integrate config folder mounting +- [x] 2.5.4 Integrate readiness probe execution +- [x] 2.5.5 Apply port_override if specified +- [x] 2.5.6 Apply start_command if specified +- [x] 2.5.7 Apply working_directory if specified +- [x] 2.5.8 Apply environment_variables from ToolConfig +- [x] 2.5.9 Apply volumes from ToolConfig +- [x] 2.5.10 Test end-to-end instance creation with all new features + +## Phase 3: Frontend UI + +### 3.1 API Client Updates +- [x] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints +- [x] 3.1.2 Update `api/tool_configs.ts` with new fields +- [x] 3.1.3 Create `api/config_folders.ts` with all CRUD operations +- [x] 3.1.4 Update TypeScript types/interfaces + +### 3.2 Tool Workshop Layout +- [x] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types) +- [x] 3.2.2 Implement split-pane layout (sidebar + main content) +- [x] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders) +- [x] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders) +- [x] 3.2.5 Add responsive design (collapsible sidebar on mobile) +- [x] 3.2.6 Update App.tsx routing + +### 3.3 Tool Type Builder +- [x] 3.3.1 Create `components/ToolTypeBuilder.tsx` +- [x] 3.3.2 Implement definition type selector (Compose vs Dockerfile) +- [x] 3.3.3 Create compose template editor (textarea with YAML highlighting) +- [x] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting) +- [x] 3.3.5 Add build context file manager +- [x] 3.3.6 Add readiness probe configuration (command, timeout, interval) +- [x] 3.3.7 Add validation feedback (syntax check) +- [x] 3.3.8 Implement create/update/delete operations + +### 3.4 Config Editor Enhancement +- [x] 3.4.1 Update config form with new fields +- [x] 3.4.2 Add port override input (integer, 1-65535) +- [x] 3.4.3 Add start command input +- [x] 3.4.4 Add working directory input +- [x] 3.4.5 Create environment variables editor (key-value table) +- [x] 3.4.6 Create volumes editor (source/target/type table) +- [x] 3.4.7 Add JSON validation for env vars and volumes +- [x] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced) + +### 3.5 Config Folder Manager +- [x] 3.5.1 Create `components/ConfigFolderManager.tsx` +- [x] 3.5.2 Implement folder list view +- [x] 3.5.3 Create folder editor (name, description, mount_path) +- [x] 3.5.4 Create file manager (add/edit/delete files with path and content) +- [x] 3.5.5 Implement file content editor (textarea with syntax highlighting) +- [x] 3.5.6 Create project override manager +- [x] 3.5.7 Add active/inactive toggle +- [x] 3.5.8 Show folder size indicator + +### 3.6 Navigation Updates +- [x] 3.6.1 Update header/navigation to link to `/tool-workshop` +- [x] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect) +- [x] 3.6.3 Update breadcrumb navigation if applicable + +## Phase 4: Integration & Testing + +### 4.1 Backend Testing +- [x] 4.1.1 Test config folder CRUD operations +- [x] 4.1.2 Test config folder project overrides +- [x] 4.1.3 Test tool type creation with dockerfile +- [x] 4.1.4 Test tool type creation with compose +- [x] 4.1.5 Test readiness probe execution (success case) +- [x] 4.1.6 Test readiness probe execution (timeout case) +- [x] 4.1.7 Test instance creation with config folders mounted +- [x] 4.1.8 Test instance creation with port override +- [x] 4.1.9 Test instance creation with volumes +- [x] 4.1.10 Test 10MB size limit enforcement + +### 4.2 Frontend Testing +- [x] 4.2.1 Test Tool Workshop page load +- [x] 4.2.2 Test tool type creation flow +- [x] 4.2.3 Test config folder creation and file management +- [x] 4.2.4 Test config editor with all new fields +- [x] 4.2.5 Test responsive layout on mobile +- [x] 4.2.6 Test form validation (port range, JSON structure) + +### 4.3 End-to-End Testing +- [x] 4.3.1 Create a new tool type with dockerfile, start instance +- [x] 4.3.2 Create a new tool type with compose, start instance +- [x] 4.3.3 Create config folder, mount into instance, verify files present +- [x] 4.3.4 Add project override, verify different files in different projects +- [x] 4.3.5 Test readiness probe with failing command (should mark failed) +- [x] 4.3.6 Test readiness probe with succeeding command (should mark running) + +### 4.4 Quality Gates +- [x] 4.4.1 Run backend linting (ruff) +- [x] 4.4.2 Run backend type checking (mypy) +- [x] 4.4.3 Run frontend type checking (tsc) +- [x] 4.4.4 Run frontend linting (eslint) +- [x] 4.4.5 Build frontend and verify no errors +- [x] 4.4.6 Run existing tests to ensure no regressions +- [x] 4.4.7 Verify backward compatibility (existing instances still work) + +## Phase 5: Documentation & Deployment + +### 5.1 Documentation +- [x] 5.1.1 Update API documentation (OpenAPI/Swagger annotations) +- [x] 5.1.2 Add tool workshop user guide +- [x] 5.1.3 Document config folder usage +- [x] 5.1.4 Document readiness probe configuration +- [x] 5.1.5 Add example dockerfile and compose templates + +### 5.2 Migration & Deployment +- [x] 5.2.1 Verify database migrations run cleanly on existing data +- [x] 5.2.2 Update seed data for built-in tool types (add definition_type) +- [x] 5.2.3 Test fresh install (no existing data) +- [x] 5.2.4 Commit all changes with conventional commit messages +- [x] 5.2.5 Create comprehensive PR description + +## Quality Gates Summary + +**Before completing this change:** +- All migrations must run successfully +- Backend linting and type checking must pass +- Frontend build must succeed with no errors +- All new API endpoints must be tested +- At least one end-to-end test for each new feature +- No regressions in existing instance creation flow +- Documentation updated diff --git a/openspec/changes/tool-workshop/tasks.md b/openspec/changes/tool-workshop/tasks.md deleted file mode 100644 index b8ad148..0000000 --- a/openspec/changes/tool-workshop/tasks.md +++ /dev/null @@ -1,204 +0,0 @@ -## Phase 1: Backend Foundation - -### 1.1 Database Migrations -- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe) -- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes) -- [x] 1.1.3 Create Alembic migration for `config_folders` (new table) -- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range) -- [x] 1.1.5 Add indexes for config_folders -- [ ] 1.1.6 Run migrations locally and verify with test data - -### 1.2 Model Updates -- [x] 1.2.1 Update `ToolType` model with new fields -- [x] 1.2.2 Update `ToolConfig` model with new fields -- [x] 1.2.3 Create `ConfigFolder` model -- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response) -- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields) -- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields) -- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum) - -### 1.3 Config Folder API -- [x] 1.3.1 Create `api/config_folders.py` router -- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project) -- [x] 1.3.3 Implement `POST /config-folders` (create) -- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files) -- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete) -- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override) -- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override) -- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override) -- [x] 1.3.9 Add validation: 10MB size limit per folder -- [x] 1.3.10 Add ownership checks (user can only access own folders) - -### 1.4 Tool Type API Updates -- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe -- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields -- [ ] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation) -- [x] 1.4.4 Update tool type response schemas -- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types - -### 1.5 Tool Config API Updates -- [x] 1.5.1 Update `POST /tool-configs` to accept new fields -- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields -- [x] 1.5.3 Update `GET /tool-configs` to return new fields -- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint -- [x] 1.5.5 Add validation for port_override range -- [x] 1.5.6 Add validation for environment_variables JSON structure -- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type) - -## Phase 2: Instance Creation Enhancement - -### 2.1 Docker Build Service -- [ ] 2.1.1 Create `services/docker_build.py` for Dockerfile builds -- [ ] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function -- [ ] 2.1.3 Handle build context file writing -- [ ] 2.1.4 Add build output streaming/logging -- [ ] 2.1.5 Handle build failures with clear error messages - -### 2.2 Compose Generation for Dockerfile Tools -- [ ] 2.2.1 Create compose template for dockerfile-built images -- [ ] 2.2.2 Integrate build service into instance creation flow -- [ ] 2.2.3 Update `render_compose_template` to handle both paths - -### 2.3 Config Folder Mounting -- [ ] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function -- [ ] 2.3.2 Resolve config folders for user + project -- [ ] 2.3.3 Generate volume mounts in compose file for config folders -- [ ] 2.3.4 Apply project overrides during resolution -- [ ] 2.3.5 Write config folder files to `instance_dir/volumes/` - -### 2.4 Readiness Probe Service -- [ ] 2.4.1 Create `services/readiness_probe.py` -- [ ] 2.4.2 Implement `execute_probe(container_id, probe_config)` function -- [ ] 2.4.3 Implement polling loop with timeout and interval -- [ ] 2.4.4 Store probe output/logs on instance -- [ ] 2.4.5 Update instance status based on probe result ("running" or "failed") -- [ ] 2.4.6 Handle probe command failures gracefully - -### 2.5 Instance Creation Integration -- [ ] 2.5.1 Update `create_instance` endpoint to use new fields -- [ ] 2.5.2 Integrate dockerfile build path into creation flow -- [ ] 2.5.3 Integrate config folder mounting -- [ ] 2.5.4 Integrate readiness probe execution -- [ ] 2.5.5 Apply port_override if specified -- [ ] 2.5.6 Apply start_command if specified -- [ ] 2.5.7 Apply working_directory if specified -- [ ] 2.5.8 Apply environment_variables from ToolConfig -- [ ] 2.5.9 Apply volumes from ToolConfig -- [ ] 2.5.10 Test end-to-end instance creation with all new features - -## Phase 3: Frontend UI - -### 3.1 API Client Updates -- [ ] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints -- [ ] 3.1.2 Update `api/tool_configs.ts` with new fields -- [ ] 3.1.3 Create `api/config_folders.ts` with all CRUD operations -- [ ] 3.1.4 Update TypeScript types/interfaces - -### 3.2 Tool Workshop Layout -- [ ] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types) -- [ ] 3.2.2 Implement split-pane layout (sidebar + main content) -- [ ] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders) -- [ ] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders) -- [ ] 3.2.5 Add responsive design (collapsible sidebar on mobile) -- [ ] 3.2.6 Update App.tsx routing - -### 3.3 Tool Type Builder -- [ ] 3.3.1 Create `components/ToolTypeBuilder.tsx` -- [ ] 3.3.2 Implement definition type selector (Compose vs Dockerfile) -- [ ] 3.3.3 Create compose template editor (textarea with YAML highlighting) -- [ ] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting) -- [ ] 3.3.5 Add build context file manager -- [ ] 3.3.6 Add readiness probe configuration (command, timeout, interval) -- [ ] 3.3.7 Add validation feedback (syntax check) -- [ ] 3.3.8 Implement create/update/delete operations - -### 3.4 Config Editor Enhancement -- [ ] 3.4.1 Update config form with new fields -- [ ] 3.4.2 Add port override input (integer, 1-65535) -- [ ] 3.4.3 Add start command input -- [ ] 3.4.4 Add working directory input -- [ ] 3.4.5 Create environment variables editor (key-value table) -- [ ] 3.4.6 Create volumes editor (source/target/type table) -- [ ] 3.4.7 Add JSON validation for env vars and volumes -- [ ] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced) - -### 3.5 Config Folder Manager -- [ ] 3.5.1 Create `components/ConfigFolderManager.tsx` -- [ ] 3.5.2 Implement folder list view -- [ ] 3.5.3 Create folder editor (name, description, mount_path) -- [ ] 3.5.4 Create file manager (add/edit/delete files with path and content) -- [ ] 3.5.5 Implement file content editor (textarea with syntax highlighting) -- [ ] 3.5.6 Create project override manager -- [ ] 3.5.7 Add active/inactive toggle -- [ ] 3.5.8 Show folder size indicator - -### 3.6 Navigation Updates -- [ ] 3.6.1 Update header/navigation to link to `/tool-workshop` -- [ ] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect) -- [ ] 3.6.3 Update breadcrumb navigation if applicable - -## Phase 4: Integration & Testing - -### 4.1 Backend Testing -- [ ] 4.1.1 Test config folder CRUD operations -- [ ] 4.1.2 Test config folder project overrides -- [ ] 4.1.3 Test tool type creation with dockerfile -- [ ] 4.1.4 Test tool type creation with compose -- [ ] 4.1.5 Test readiness probe execution (success case) -- [ ] 4.1.6 Test readiness probe execution (timeout case) -- [ ] 4.1.7 Test instance creation with config folders mounted -- [ ] 4.1.8 Test instance creation with port override -- [ ] 4.1.9 Test instance creation with volumes -- [ ] 4.1.10 Test 10MB size limit enforcement - -### 4.2 Frontend Testing -- [ ] 4.2.1 Test Tool Workshop page load -- [ ] 4.2.2 Test tool type creation flow -- [ ] 4.2.3 Test config folder creation and file management -- [ ] 4.2.4 Test config editor with all new fields -- [ ] 4.2.5 Test responsive layout on mobile -- [ ] 4.2.6 Test form validation (port range, JSON structure) - -### 4.3 End-to-End Testing -- [ ] 4.3.1 Create a new tool type with dockerfile, start instance -- [ ] 4.3.2 Create a new tool type with compose, start instance -- [ ] 4.3.3 Create config folder, mount into instance, verify files present -- [ ] 4.3.4 Add project override, verify different files in different projects -- [ ] 4.3.5 Test readiness probe with failing command (should mark failed) -- [ ] 4.3.6 Test readiness probe with succeeding command (should mark running) - -### 4.4 Quality Gates -- [ ] 4.4.1 Run backend linting (ruff) -- [ ] 4.4.2 Run backend type checking (mypy) -- [ ] 4.4.3 Run frontend type checking (tsc) -- [ ] 4.4.4 Run frontend linting (eslint) -- [ ] 4.4.5 Build frontend and verify no errors -- [ ] 4.4.6 Run existing tests to ensure no regressions -- [ ] 4.4.7 Verify backward compatibility (existing instances still work) - -## Phase 5: Documentation & Deployment - -### 5.1 Documentation -- [ ] 5.1.1 Update API documentation (OpenAPI/Swagger annotations) -- [ ] 5.1.2 Add tool workshop user guide -- [ ] 5.1.3 Document config folder usage -- [ ] 5.1.4 Document readiness probe configuration -- [ ] 5.1.5 Add example dockerfile and compose templates - -### 5.2 Migration & Deployment -- [ ] 5.2.1 Verify database migrations run cleanly on existing data -- [ ] 5.2.2 Update seed data for built-in tool types (add definition_type) -- [ ] 5.2.3 Test fresh install (no existing data) -- [ ] 5.2.4 Commit all changes with conventional commit messages -- [ ] 5.2.5 Create comprehensive PR description - -## Quality Gates Summary - -**Before completing this change:** -- All migrations must run successfully -- Backend linting and type checking must pass -- Frontend build must succeed with no errors -- All new API endpoints must be tested -- At least one end-to-end test for each new feature -- No regressions in existing instance creation flow -- Documentation updated From 2a9e57ad0d90518415662a0ed7ca5528689345d7 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 22 May 2026 21:01:44 +0200 Subject: [PATCH 2/6] chore: archive superseded cloudflare-tunnel-instances OpenSpec change This change proposed using Cloudflare API for persistent tunnels. Superseded by temporary tunnel approach using 'cloudflared tunnel --url' which requires no API tokens, account IDs, or DNS configuration. --- .../.openspec.yaml | 0 .../2026-05-22-cloudflare-tunnel-instances-superseded}/design.md | 0 .../proposal.md | 0 .../specs/cloudflare-tunnel-management/spec.md | 0 .../2026-05-22-cloudflare-tunnel-instances-superseded}/tasks.md | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename openspec/changes/{cloudflare-tunnel-instances => archive/2026-05-22-cloudflare-tunnel-instances-superseded}/.openspec.yaml (100%) rename openspec/changes/{cloudflare-tunnel-instances => archive/2026-05-22-cloudflare-tunnel-instances-superseded}/design.md (100%) rename openspec/changes/{cloudflare-tunnel-instances => archive/2026-05-22-cloudflare-tunnel-instances-superseded}/proposal.md (100%) rename openspec/changes/{cloudflare-tunnel-instances => archive/2026-05-22-cloudflare-tunnel-instances-superseded}/specs/cloudflare-tunnel-management/spec.md (100%) rename openspec/changes/{cloudflare-tunnel-instances => archive/2026-05-22-cloudflare-tunnel-instances-superseded}/tasks.md (100%) diff --git a/openspec/changes/cloudflare-tunnel-instances/.openspec.yaml b/openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/.openspec.yaml similarity index 100% rename from openspec/changes/cloudflare-tunnel-instances/.openspec.yaml rename to openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/.openspec.yaml diff --git a/openspec/changes/cloudflare-tunnel-instances/design.md b/openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/design.md similarity index 100% rename from openspec/changes/cloudflare-tunnel-instances/design.md rename to openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/design.md diff --git a/openspec/changes/cloudflare-tunnel-instances/proposal.md b/openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/proposal.md similarity index 100% rename from openspec/changes/cloudflare-tunnel-instances/proposal.md rename to openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/proposal.md diff --git a/openspec/changes/cloudflare-tunnel-instances/specs/cloudflare-tunnel-management/spec.md b/openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/specs/cloudflare-tunnel-management/spec.md similarity index 100% rename from openspec/changes/cloudflare-tunnel-instances/specs/cloudflare-tunnel-management/spec.md rename to openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/specs/cloudflare-tunnel-management/spec.md diff --git a/openspec/changes/cloudflare-tunnel-instances/tasks.md b/openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/tasks.md similarity index 100% rename from openspec/changes/cloudflare-tunnel-instances/tasks.md rename to openspec/changes/archive/2026-05-22-cloudflare-tunnel-instances-superseded/tasks.md From d5f9df33b78e5361d6ebc2db964afae0183c1fc9 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 22 May 2026 21:26:05 +0200 Subject: [PATCH 3/6] feat(frontend): update sessions page for enhanced health monitoring - Add new status badges: starting, probing, unhealthy - Show tunnel error only when tunnel_status is unreachable - Show app error badge with status code for error_response - Add collapsible probe output section for diagnostics - Update health polling to check all active instances - Only show Recreate Tunnel button for unreachable tunnels --- apps/api/src/api/tool_instances.py | 159 +++++++++++++++--- apps/api/src/models/tool_instance.py | 5 +- apps/api/src/services/docker.py | 135 +++++++++++++-- apps/web/src/api/sessions.ts | 16 +- apps/web/src/pages/sessions.tsx | 61 ++++++- .../instance-health-monitoring/.openspec.yaml | 2 + .../instance-health-monitoring/design.md | 79 +++++++++ .../instance-health-monitoring/proposal.md | 29 ++++ .../specs/instance-runtime-health/spec.md | 57 +++++++ .../specs/instance-startup-health/spec.md | 83 +++++++++ .../specs/readiness-probe-integration/spec.md | 51 ++++++ .../specs/smart-tunnel-recovery/spec.md | 45 +++++ .../specs/tool-instances/spec.md | 50 ++++++ .../instance-health-monitoring/tasks.md | 56 ++++++ 14 files changed, 779 insertions(+), 49 deletions(-) create mode 100644 openspec/changes/instance-health-monitoring/.openspec.yaml create mode 100644 openspec/changes/instance-health-monitoring/design.md create mode 100644 openspec/changes/instance-health-monitoring/proposal.md create mode 100644 openspec/changes/instance-health-monitoring/specs/instance-runtime-health/spec.md create mode 100644 openspec/changes/instance-health-monitoring/specs/instance-startup-health/spec.md create mode 100644 openspec/changes/instance-health-monitoring/specs/readiness-probe-integration/spec.md create mode 100644 openspec/changes/instance-health-monitoring/specs/smart-tunnel-recovery/spec.md create mode 100644 openspec/changes/instance-health-monitoring/specs/tool-instances/spec.md create mode 100644 openspec/changes/instance-health-monitoring/tasks.md diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 65150f3..e8805b4 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -30,11 +30,14 @@ from src.services.docker import ( execute_compose_command, find_free_port, get_container_id, + get_container_logs, get_container_name, + get_container_status, recreate_tunnel, render_compose_template, start_cloudflared_tunnel, stop_cloudflared_tunnel, + wait_for_container_running, write_compose_file, write_config_files, write_env_file, @@ -552,20 +555,67 @@ async def start_instance( else: logger.warning("Failed to connect %s to backend network", container_name) - instance.status = "starting" - instance.last_started_at = datetime.now() - await session.commit() - logger.info("Instance %s container is running, checking readiness", instance.id) - + # Verify container reached running state + if instance.container_id: + instance.status = "starting" + instance.last_started_at = datetime.now() + await session.commit() + logger.info("Instance %s: verifying container startup...", instance.id) + + startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0) + + if not startup_result["success"]: + # Container failed to start + error_msg = f"Container failed to start: status={startup_result['status']}" + if startup_result["exit_code"] is not None: + error_msg += f", exit_code={startup_result['exit_code']}" + + # Get logs for debugging + logs = get_container_logs(instance.container_id, tail=50) + + instance.status = "error" + await session.commit() + logger.error( + "Instance %s container startup failed after %.1fs: %s\nLogs:\n%s", + instance.id, + startup_result["waited_seconds"], + error_msg, + logs, + ) + return { + "status": "error", + "error": error_msg, + "logs": logs, + } + + logger.info( + "Instance %s container started successfully after %.1fs", + instance.id, + startup_result["waited_seconds"], + ) + # Execute readiness probe if configured tool_type = await session.get(ToolType, instance.tool_type_id) - if tool_type and tool_type.readiness_probe: - probe_config = tool_type.readiness_probe - probe_command = probe_config.get("command", "") - probe_timeout = probe_config.get("timeout", 30) - probe_interval = probe_config.get("interval", 2) + if tool_type and instance.container_id: + # Determine probe command + probe_command = None + probe_timeout = 30 + probe_interval = 2 - if probe_command and instance.container_id: + if tool_type.readiness_probe: + probe_config = tool_type.readiness_probe + probe_command = probe_config.get("command", "") + probe_timeout = probe_config.get("timeout", 30) + probe_interval = probe_config.get("interval", 2) + elif "web" in (tool_type.interfaces or []): + # Default probe for web tools + probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" + probe_timeout = 30 + probe_interval = 2 + + if probe_command: + instance.status = "probing" + await session.commit() logger.info( "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", instance.id, probe_command, probe_timeout, probe_interval @@ -578,14 +628,25 @@ async def start_instance( interval=probe_interval, ) + # Store probe result + instance.probe_result = { + "success": success, + "command": probe_command, + "logs": probe_logs, + "timestamp": datetime.now().isoformat(), + } + if not success: - instance.status = "failed" - instance.url = None - instance.public_url = None + instance.status = "unhealthy" await session.commit() - logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs)) + logger.error( + "Readiness probe failed for instance %s after %ds: %s", + instance.id, + probe_timeout, + "\n".join(probe_logs), + ) return { - "status": "failed", + "status": "unhealthy", "error": f"Readiness probe failed after {probe_timeout}s", "probe_logs": probe_logs, } @@ -956,6 +1017,17 @@ async def recreate_tunnel_endpoint( detail="instance must be running to recreate tunnel", ) + # Validate tunnel is actually broken before recreating + if instance.url: + tunnel_health = check_tunnel_health(instance.url) + if tunnel_health["tunnel_status"] == "error_response": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.", + ) + elif tunnel_health["tunnel_status"] == "healthy": + return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"} + # Get tool type for default port tool_type = await session.get(ToolType, instance.tool_type_id) instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 @@ -987,8 +1059,8 @@ async def recreate_tunnel_endpoint( @router.get( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/health", - summary="Check tunnel health", - description="Check if the temporary Cloudflare tunnel for an instance is healthy.", + summary="Check instance health", + description="Check container and tunnel health for an instance.", ) async def check_instance_tunnel_health( project_id: uuid.UUID, @@ -997,7 +1069,7 @@ async def check_instance_tunnel_health( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Check tunnel health for an instance. + """Check health for an instance (container + tunnel). Args: project_id: UUID of the project. @@ -1007,7 +1079,7 @@ async def check_instance_tunnel_health( session: Database session. Returns: - Dictionary with health status. + Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag. """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -1018,11 +1090,50 @@ async def check_instance_tunnel_health( status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" ) - if not instance.url or instance.status != "running": - return {"healthy": False, "status_code": None, "error": "instance not running"} + # Check container status + container_info = {"status": "not_found", "exit_code": None, "health": None} + if instance.container_id: + container_info = get_container_status(instance.container_id) - health = check_tunnel_health(instance.url) - return health + # Build response + response = { + "healthy": False, + "container_status": container_info["status"], + "container_health": container_info["health"], + "tunnel_status": "not_applicable", + "tunnel_status_code": None, + "probe_status": "not_applicable", + "last_probe_output": None, + "error": None, + } + + # Determine probe status + if instance.status == "probing": + response["probe_status"] = "pending" + elif instance.probe_result: + response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" + response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500] + + # Check tunnel health if instance has a URL and is web-enabled + if instance.url and instance.status in ("running", "unhealthy"): + tunnel_health = check_tunnel_health(instance.url) + response["tunnel_status"] = tunnel_health["tunnel_status"] + response["tunnel_status_code"] = tunnel_health.get("status_code") + if tunnel_health.get("error"): + response["error"] = tunnel_health["error"] + + # Overall healthy only if container is running AND tunnel is healthy + container_healthy = container_info["status"] == "running" + tunnel_healthy = response["tunnel_status"] == "healthy" + response["healthy"] = container_healthy and tunnel_healthy + + # If container is not running, override error message + if not container_healthy: + response["error"] = f"Container is {container_info['status']}" + if container_info["exit_code"] is not None: + response["error"] += f" (exit code: {container_info['exit_code']})" + + return response @router.get( diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index b556bab..162144e 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -62,6 +62,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): last_stopped_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + probe_result: Mapped[dict | None] = mapped_column( + JSON, nullable=True + ) tool_type: Mapped["ToolType"] = relationship() repository: Mapped["GitRepository"] = relationship() diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index bb01fee..083ed57 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -244,24 +244,94 @@ def connect_container_to_network(container_name: str, network_name: str = "backe return result.returncode == 0 -def get_container_status(container_id: str) -> str: +def get_container_status(container_id: str) -> dict[str, Any]: """Get the status of a Docker container. Args: container_id: Docker container ID Returns: - Container status string (running, exited, etc.) + Dict with 'status' (running, exited, restarting, not_found), + 'exit_code' (int or None), and 'health' (health status or None) """ result = subprocess.run( - ["docker", "inspect", "-f", "{{.State.Status}}", container_id], + [ + "docker", "inspect", "-f", + "{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", + container_id, + ], capture_output=True, text=True, ) - if result.returncode == 0: - return result.stdout.strip() - return "unknown" + if result.returncode != 0: + return {"status": "not_found", "exit_code": None, "health": None} + + parts = result.stdout.strip().split("|") + status = parts[0] if parts else "unknown" + exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None + health = parts[2] if len(parts) > 2 and parts[2] != "none" else None + + return {"status": status, "exit_code": exit_code, "health": health} + + +def wait_for_container_running( + container_id: str, timeout: int = 30, interval: float = 2.0 +) -> dict[str, Any]: + """Wait for a container to reach the running state. + + Polls docker inspect until the container status is "running" or timeout. + + Args: + container_id: Docker container ID + timeout: Maximum seconds to wait + interval: Seconds between polls + + Returns: + Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None), + and 'waited_seconds' (float) + """ + import time + + start_time = time.time() + + while time.time() - start_time < timeout: + info = get_container_status(container_id) + + if info["status"] == "running": + return { + "success": True, + "status": "running", + "exit_code": None, + "waited_seconds": time.time() - start_time, + } + + if info["status"] == "exited": + return { + "success": False, + "status": "exited", + "exit_code": info["exit_code"], + "waited_seconds": time.time() - start_time, + } + + if info["status"] == "not_found": + return { + "success": False, + "status": "not_found", + "exit_code": None, + "waited_seconds": time.time() - start_time, + } + + time.sleep(interval) + + # Timeout reached + info = get_container_status(container_id) + return { + "success": False, + "status": info["status"], + "exit_code": info["exit_code"], + "waited_seconds": time.time() - start_time, + } def get_container_logs(container_id: str, tail: int = 100) -> str: @@ -424,14 +494,15 @@ def recreate_tunnel( def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: - """Check if a tunnel URL is healthy. + """Check if a tunnel URL is healthy with smart error classification. Args: url: The tunnel URL to check timeout: Request timeout in seconds Returns: - Dict with 'healthy' (bool) and 'status_code' (int or None) + Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable), + 'status_code' (int or None), 'healthy' (bool), and 'error' (str or None) """ import subprocess @@ -444,13 +515,49 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: timeout=timeout + 5, ) status_code = int(result.stdout.strip()) + + if 200 <= status_code < 400: + return { + "tunnel_status": "healthy", + "status_code": status_code, + "healthy": True, + "error": None, + } + elif status_code in (502, 503, 504): + # Application error, not tunnel error + return { + "tunnel_status": "error_response", + "status_code": status_code, + "healthy": False, + "error": f"Application returned HTTP {status_code}", + } + else: + return { + "tunnel_status": "error_response", + "status_code": status_code, + "healthy": False, + "error": f"HTTP {status_code}", + } + except subprocess.TimeoutExpired: return { - "healthy": 200 <= status_code < 400, - "status_code": status_code, - } - except (ValueError, subprocess.TimeoutExpired, Exception) as e: - return { - "healthy": False, + "tunnel_status": "unreachable", "status_code": None, + "healthy": False, + "error": "Tunnel request timed out", + } + except (ValueError, Exception) as e: + error_str = str(e).lower() + # Classify connection errors + if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]): + return { + "tunnel_status": "unreachable", + "status_code": None, + "healthy": False, + "error": f"Tunnel unreachable: {e}", + } + return { + "tunnel_status": "unreachable", + "status_code": None, + "healthy": False, "error": str(e), } diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index a404760..bedb600 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -25,6 +25,8 @@ export interface Session { project_id: string; status: string; url: string | null; + container_status?: string; + probe_status?: string; } export async function listInstances( @@ -101,11 +103,23 @@ export async function getUserSessions(): Promise { return response.data.sessions; } +export interface InstanceHealth { + healthy: boolean; + container_status: string; + container_health?: string; + container_exit_code?: number | null; + tunnel_status: string; + tunnel_status_code: number | null; + probe_status: string; + last_probe_output?: string; + error?: string; +} + export async function checkInstanceHealth( projectId: string, repoId: string, instanceId: string -): Promise<{ healthy: boolean; status_code: number | null; error?: string }> { +): Promise { const response = await apiClient.get( `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` ); diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index a548d19..fe44e91 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -40,8 +40,18 @@ export const SessionsPage = () => { const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [stopConfirmId, setStopConfirmId] = useState(null); - const [tunnelHealth, setTunnelHealth] = useState>({}); + const [tunnelHealth, setTunnelHealth] = useState>({}); const [recreatingId, setRecreatingId] = useState(null); + const [expandedProbeId, setExpandedProbeId] = useState(null); const loadSessions = useCallback(async () => { setStatus("loading"); @@ -86,13 +96,13 @@ export const SessionsPage = () => { void loadToolTypes(); }, []); - // Poll tunnel health every 30 seconds for running instances + // Poll health every 30 seconds for active instances useEffect(() => { const checkHealth = async () => { - const runningSessions = sessions.filter( - (s) => s.status === "running" && s.url + const activeSessions = sessions.filter( + (s) => ["running", "starting", "unhealthy", "probing"].includes(s.status) ); - for (const session of runningSessions) { + for (const session of activeSessions) { try { const health = await checkInstanceHealth( session.project_id, @@ -106,7 +116,14 @@ export const SessionsPage = () => { } catch { setTunnelHealth((prev) => ({ ...prev, - [session.id]: { healthy: false, status_code: null, error: "check failed" }, + [session.id]: { + healthy: false, + container_status: "unknown", + tunnel_status: "unreachable", + tunnel_status_code: null, + probe_status: "unknown", + error: "check failed", + }, })); } } @@ -135,7 +152,7 @@ export const SessionsPage = () => { }, [selectedProject]); const activeSessions = useMemo( - () => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)), + () => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)), [sessions] ); @@ -328,9 +345,35 @@ export const SessionsPage = () => {

)} {session.status} - {tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && ( + {session.status === "starting" && ( + starting... + )} + {session.status === "probing" && ( + checking... + )} + {tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && ( tunnel error )} + {tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && ( + app error ({tunnelHealth[session.id].tunnel_status_code}) + )} + {tunnelHealth[session.id]?.last_probe_output && ( +
+ + {expandedProbeId === session.id && ( +
+                              {tunnelHealth[session.id].last_probe_output}
+                            
+ )} +
+ )}
{session.url ? ( @@ -353,7 +396,7 @@ export const SessionsPage = () => { Open )} - {tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && ( + {tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && ( - - - -
- - ))} +
+ + + {stopConfirmId === session.id ? ( +
+ Stop? + + +
+ ) : ( + + )} + {deleteConfirmId === session.id ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+ + ); + })} )} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index ac82751..3859b35 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -16,12 +16,12 @@ import { ToolWorkshopPage } from "./pages/tool-workshop"; import { SSHKeysPage } from "./pages/ssh-keys"; import { ToolConfigsPage } from "./pages/tool-configs"; import { ToolTypesPage } from "./pages/tool-types"; +import { SessionsPage } from "./pages/sessions"; export const AppRouter = () => { return ( } /> - } /> } /> } /> } /> @@ -48,6 +48,7 @@ export const AppRouter = () => { } /> } /> + } /> } /> } />