mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
Migrations and version APIs initial
This commit is contained in:
622
PRPs/version-and-migration-system-implementation.md
Normal file
622
PRPs/version-and-migration-system-implementation.md
Normal file
@@ -0,0 +1,622 @@
|
||||
name: "Version Checking and Migration Tracking System Implementation PRP"
|
||||
description: |
|
||||
Complete implementation guide for Archon's version checking and migration tracking system with comprehensive research and patterns
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
**Feature Goal**: Implement a comprehensive version checking and migration tracking system for Archon that enables users to stay up-to-date and manage database schema changes safely when upgrading.
|
||||
|
||||
**Deliverable**: Backend API endpoints for version checking and migration status, frontend UI components integrated with settings page, self-bootstrapping migration system with tracking.
|
||||
|
||||
**Success Definition**: Users can see when updates are available, view pending migrations with SQL content, copy migration SQL to run manually in Supabase, and track migration history - all with proper error handling and caching.
|
||||
|
||||
## User Persona
|
||||
|
||||
**Target User**: Archon administrators and self-hosting users
|
||||
|
||||
**Use Case**:
|
||||
1. Check for new Archon releases when visiting settings page
|
||||
2. View pending database migrations before upgrading
|
||||
3. Copy and apply migrations manually via Supabase SQL Editor
|
||||
4. Track migration history to verify system state
|
||||
|
||||
**User Journey**:
|
||||
1. User visits Settings page → sees version status (current vs latest)
|
||||
2. If update available → sees prominent alert with link to release notes
|
||||
3. Checks migrations section → sees X pending migrations
|
||||
4. Opens pending migrations modal → views SQL content for each
|
||||
5. Copies SQL → runs in Supabase → migration self-records
|
||||
6. Refreshes status → sees migration applied successfully
|
||||
|
||||
**Pain Points Addressed**:
|
||||
- No visibility into available updates
|
||||
- Database schema changes break functionality after git pull
|
||||
- No tracking of which migrations have been applied
|
||||
- Manual migration process lacks guidance
|
||||
|
||||
## Why
|
||||
|
||||
- **Business value**: Reduces support burden by helping users stay current and manage upgrades properly
|
||||
- **Integration**: Extends existing settings page with new capabilities following established patterns
|
||||
- **Problems solved**: Version awareness, migration tracking, safe schema evolution during beta phase
|
||||
|
||||
## What
|
||||
|
||||
Implement version checking against GitHub releases API, database migration tracking with self-recording pattern, and intuitive UI for managing both. System must handle bootstrap case (no migrations table), cache GitHub API responses to avoid rate limits, and provide clear migration guidance for Supabase's manual SQL execution requirement.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- [ ] Version checking shows current vs latest with update availability
|
||||
- [ ] Migration system tracks applied vs pending migrations accurately
|
||||
- [ ] Bootstrap case handled automatically when migrations table doesn't exist
|
||||
- [ ] GitHub API cached for 1 hour to avoid rate limits
|
||||
- [ ] Migrations include self-recording SQL at the end
|
||||
- [ ] UI provides clear copy buttons for migration SQL
|
||||
- [ ] Error states handled gracefully with fallback behavior
|
||||
|
||||
## All Needed Context
|
||||
|
||||
### Context Completeness Check
|
||||
|
||||
_This PRP includes comprehensive research findings, existing codebase patterns, external API documentation, and detailed implementation patterns needed for one-pass implementation success._
|
||||
|
||||
### Documentation & References
|
||||
|
||||
```yaml
|
||||
# MUST READ - Include these in your context window
|
||||
- url: https://docs.github.com/en/rest/releases/releases#get-the-latest-release
|
||||
why: GitHub API endpoint for fetching latest release with exact response schema
|
||||
critical: Returns 404 when no releases exist yet - must handle gracefully
|
||||
|
||||
- file: python/src/server/api_routes/progress_api.py
|
||||
why: Pattern for API routes with ETag support and error handling
|
||||
pattern: Router setup, ETag generation/checking, HTTPException handling
|
||||
gotcha: ETags use generate_etag from utils, check_etag returns boolean
|
||||
|
||||
- file: python/src/server/services/credential_service.py
|
||||
why: Service layer pattern with Supabase client initialization
|
||||
pattern: Service class with async methods, _get_supabase_client pattern
|
||||
gotcha: Must handle Supabase connection failures gracefully
|
||||
|
||||
- file: archon-ui-main/src/features/projects/hooks/useProjectQueries.ts
|
||||
why: TanStack Query v5 patterns with query keys factory
|
||||
pattern: Query keys factory, optimistic updates, smart polling usage
|
||||
gotcha: Use DISABLED_QUERY_KEY for disabled queries, STALE_TIMES constants
|
||||
|
||||
- file: archon-ui-main/src/features/projects/services/projectService.ts
|
||||
why: Frontend service pattern using callAPIWithETag
|
||||
pattern: Service object with async methods, error handling
|
||||
gotcha: Always use callAPIWithETag for consistency
|
||||
|
||||
- docfile: PRPs/ai_docs/QUERY_PATTERNS.md
|
||||
why: Complete TanStack Query patterns and conventions
|
||||
section: Query key factories, shared patterns usage
|
||||
|
||||
- docfile: PRPs/ai_docs/ARCHITECTURE.md
|
||||
why: System architecture and directory structure conventions
|
||||
section: Backend structure, Frontend vertical slices
|
||||
```
|
||||
|
||||
### Current Codebase tree
|
||||
|
||||
```bash
|
||||
python/
|
||||
├── src/
|
||||
│ └── server/
|
||||
│ ├── api_routes/
|
||||
│ │ ├── settings_api.py
|
||||
│ │ ├── progress_api.py
|
||||
│ │ └── projects_api.py
|
||||
│ ├── services/
|
||||
│ │ ├── credential_service.py
|
||||
│ │ └── source_management_service.py
|
||||
│ ├── config/
|
||||
│ │ ├── config.py
|
||||
│ │ └── logfire_config.py
|
||||
│ ├── utils/
|
||||
│ │ └── etag_utils.py
|
||||
│ └── main.py
|
||||
|
||||
archon-ui-main/
|
||||
├── src/
|
||||
│ ├── pages/
|
||||
│ │ └── SettingsPage.tsx
|
||||
│ ├── features/
|
||||
│ │ ├── projects/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ └── types/
|
||||
│ │ └── shared/
|
||||
│ │ ├── apiWithEtag.ts
|
||||
│ │ └── queryPatterns.ts
|
||||
│ └── components/
|
||||
│ └── settings/
|
||||
|
||||
migration/
|
||||
├── 0.1.0/
|
||||
│ ├── 001_add_source_url_display_name.sql
|
||||
│ ├── 002_add_hybrid_search_tsvector.sql
|
||||
│ ├── 003_ollama_implementation.sql
|
||||
│ └── 004_add_priority_column_to_tasks.sql
|
||||
```
|
||||
|
||||
### Desired Codebase tree with files to be added
|
||||
|
||||
```bash
|
||||
python/
|
||||
├── src/
|
||||
│ └── server/
|
||||
│ ├── api_routes/
|
||||
│ │ ├── version_api.py # NEW: Version checking endpoints
|
||||
│ │ └── migration_api.py # NEW: Migration tracking endpoints
|
||||
│ ├── services/
|
||||
│ │ ├── version_service.py # NEW: GitHub API integration
|
||||
│ │ └── migration_service.py # NEW: Migration scanning/tracking
|
||||
│ ├── config/
|
||||
│ │ └── version.py # NEW: Version constant
|
||||
│ └── utils/
|
||||
│ └── semantic_version.py # NEW: Version comparison utilities
|
||||
|
||||
archon-ui-main/
|
||||
├── src/
|
||||
│ └── features/
|
||||
│ └── settings/ # NEW: Settings feature slice
|
||||
│ ├── version/ # NEW: Version checking feature
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── VersionStatusCard.tsx
|
||||
│ │ │ ├── UpdateBanner.tsx
|
||||
│ │ │ └── UpgradeInstructionsModal.tsx
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useVersionQueries.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── versionService.ts
|
||||
│ │ └── types/
|
||||
│ │ └── index.ts
|
||||
│ └── migrations/ # NEW: Migration tracking feature
|
||||
│ ├── components/
|
||||
│ │ ├── MigrationStatusCard.tsx
|
||||
│ │ ├── PendingMigrationsModal.tsx
|
||||
│ │ └── MigrationHistory.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── useMigrationQueries.ts
|
||||
│ ├── services/
|
||||
│ │ └── migrationService.ts
|
||||
│ └── types/
|
||||
│ └── index.ts
|
||||
|
||||
migration/
|
||||
├── 0.1.0/
|
||||
│ └── 005_add_migration_tracking.sql # NEW: Creates archon_migrations table
|
||||
```
|
||||
|
||||
### Known Gotchas & Library Quirks
|
||||
|
||||
```python
|
||||
# CRITICAL: GitHub API returns 404 when repository has no releases
|
||||
# Must handle this case and return update_available: false
|
||||
|
||||
# CRITICAL: Supabase cannot execute SQL programmatically via SDK
|
||||
# Users must manually copy and run migrations in Supabase SQL Editor
|
||||
|
||||
# CRITICAL: Bootstrap case - migrations table may not exist on first run
|
||||
# Migration service must check table existence before querying
|
||||
|
||||
# CRITICAL: Frontend components in components/settings/ are legacy
|
||||
# New features should go in features/settings/ following vertical slice
|
||||
|
||||
# CRITICAL: All database tables must use archon_ prefix
|
||||
# This is Supabase convention for application tables
|
||||
|
||||
# CRITICAL: ETag checking returns True if client cache is stale
|
||||
# Confusing but check_etag(if_none_match, current) returns True when NOT matching
|
||||
|
||||
# CRITICAL: Use existing copyToClipboard utility for clipboard operations
|
||||
# Import from features/shared/utils/clipboard.ts - handles fallbacks automatically
|
||||
```
|
||||
|
||||
### GitHub API Response Format
|
||||
|
||||
```json
|
||||
// Exact response from GET /repos/{owner}/{repo}/releases/latest
|
||||
{
|
||||
"url": "https://api.github.com/repos/coleam00/Archon/releases/123456789",
|
||||
"html_url": "https://github.com/coleam00/Archon/releases/tag/v1.0.0",
|
||||
"id": 217869415,
|
||||
"author": {
|
||||
"login": "coleam00",
|
||||
"id": 102023614,
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/102023614?v=4",
|
||||
"html_url": "https://github.com/coleam00"
|
||||
},
|
||||
"tag_name": "v1.0.0",
|
||||
"target_commitish": "main",
|
||||
"name": "Release v1.0.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2025-05-12T01:53:52Z",
|
||||
"published_at": "2025-05-12T02:15:57Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 253814093,
|
||||
"name": "archon-1.0.0-linux.AppImage",
|
||||
"size": 171227028,
|
||||
"download_count": 1249,
|
||||
"browser_download_url": "https://github.com/coleam00/Archon/releases/download/v1.0.0/archon-1.0.0-linux.AppImage",
|
||||
"content_type": "application/octet-stream"
|
||||
}
|
||||
],
|
||||
"tarball_url": "https://api.github.com/repos/coleam00/Archon/tarball/v1.0.0",
|
||||
"zipball_url": "https://api.github.com/repos/coleam00/Archon/zipball/v1.0.0",
|
||||
"body": "# Release Notes\n\n## What's Changed\n* Feature X by @user in #123\n* Bug fix Y by @user in #456\n\nFull changelog: https://github.com/coleam00/Archon/compare/v0.9.0...v1.0.0",
|
||||
"reactions": {
|
||||
"total_count": 89,
|
||||
"+1": 31,
|
||||
"heart": 17
|
||||
}
|
||||
}
|
||||
|
||||
// 404 Response when no releases exist:
|
||||
{
|
||||
"message": "Not Found",
|
||||
"documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Blueprint
|
||||
|
||||
### Data models and structure
|
||||
|
||||
```python
|
||||
# python/src/server/config/version.py
|
||||
ARCHON_VERSION = "0.1.0" # Update with each release
|
||||
|
||||
# python/src/server/api_routes/version_api.py - Response models
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
class VersionCheckResponse(BaseModel):
|
||||
current: str
|
||||
latest: Optional[str]
|
||||
update_available: bool
|
||||
release_url: Optional[str]
|
||||
release_notes: Optional[str]
|
||||
published_at: Optional[datetime]
|
||||
check_error: Optional[str] = None
|
||||
|
||||
# python/src/server/api_routes/migration_api.py - Response models
|
||||
class MigrationRecord(BaseModel):
|
||||
version: str
|
||||
migration_name: str
|
||||
applied_at: datetime
|
||||
checksum: Optional[str] = None
|
||||
|
||||
class PendingMigration(BaseModel):
|
||||
version: str
|
||||
name: str
|
||||
sql_content: str
|
||||
file_path: str
|
||||
|
||||
class MigrationStatusResponse(BaseModel):
|
||||
pending_migrations: list[PendingMigration]
|
||||
applied_migrations: list[MigrationRecord]
|
||||
has_pending: bool
|
||||
bootstrap_required: bool = False
|
||||
```
|
||||
|
||||
### Implementation Tasks (ordered by dependencies)
|
||||
|
||||
```yaml
|
||||
Task 1: CREATE python/src/server/config/version.py
|
||||
- IMPLEMENT: ARCHON_VERSION constant = "0.1.0"
|
||||
- NAMING: Simple string constant for now, can evolve to dataclass later
|
||||
- PLACEMENT: Config directory with other configuration
|
||||
|
||||
Task 2: CREATE migration/0.1.0/005_add_migration_tracking.sql
|
||||
- IMPLEMENT: CREATE TABLE archon_migrations with self-recording INSERT
|
||||
- FOLLOW pattern: Existing migrations in 0.1.0/ directory
|
||||
- CRITICAL: Include ON CONFLICT DO NOTHING for idempotency
|
||||
- PLACEMENT: Next sequential number in 0.1.0 directory
|
||||
- EXACT SQL:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS archon_migrations (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
migration_name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(version, migration_name)
|
||||
);
|
||||
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_migrations_version ON archon_migrations(version);
|
||||
|
||||
-- Record this migration as applied
|
||||
INSERT INTO archon_migrations (version, migration_name)
|
||||
VALUES ('0.1.0', '005_add_migration_tracking')
|
||||
ON CONFLICT (version, migration_name) DO NOTHING;
|
||||
```
|
||||
|
||||
Task 3: CREATE python/src/server/services/migration_service.py
|
||||
- IMPLEMENT: MigrationService class with filesystem scanning
|
||||
- FOLLOW pattern: credential_service.py for Supabase client pattern
|
||||
- NAMING: get_all_migrations(), get_applied_migrations(), get_pending_migrations()
|
||||
- DEPENDENCIES: Needs Supabase client, filesystem access
|
||||
- PLACEMENT: Services directory
|
||||
|
||||
Task 4: CREATE python/src/server/services/version_service.py
|
||||
- IMPLEMENT: VersionService with GitHub API integration and caching
|
||||
- FOLLOW pattern: Service class with async methods
|
||||
- NAMING: get_latest_version(), check_for_updates()
|
||||
- DEPENDENCIES: httpx for GitHub API, in-memory cache with TTL
|
||||
- PLACEMENT: Services directory
|
||||
|
||||
Task 5: CREATE python/src/server/api_routes/version_api.py
|
||||
- IMPLEMENT: FastAPI router with /check endpoint
|
||||
- FOLLOW pattern: progress_api.py for router setup and ETag support
|
||||
- NAMING: GET /api/version/check
|
||||
- DEPENDENCIES: Import version_service, ETag utilities
|
||||
- PLACEMENT: API routes directory
|
||||
|
||||
Task 6: CREATE python/src/server/api_routes/migration_api.py
|
||||
- IMPLEMENT: FastAPI router with /status and /history endpoints
|
||||
- FOLLOW pattern: projects_api.py for router and response patterns
|
||||
- NAMING: GET /api/migrations/status, GET /api/migrations/history
|
||||
- DEPENDENCIES: Import migration_service
|
||||
- PLACEMENT: API routes directory
|
||||
|
||||
Task 7: MODIFY python/src/server/main.py
|
||||
- INTEGRATE: Register new routers (version_api, migration_api)
|
||||
- FIND pattern: Existing router imports and registrations
|
||||
- ADD: from .api_routes import version_api, migration_api
|
||||
- ADD: app.include_router(version_api.router), app.include_router(migration_api.router)
|
||||
- PRESERVE: Existing router registrations
|
||||
|
||||
Task 8: CREATE archon-ui-main/src/features/settings/version/types/index.ts
|
||||
- IMPLEMENT: TypeScript types matching backend response models
|
||||
- FOLLOW pattern: projects/types/index.ts for type definitions
|
||||
- NAMING: VersionCheckResponse, VersionStatus interfaces
|
||||
- PLACEMENT: New feature slice under features/settings
|
||||
|
||||
Task 9: CREATE archon-ui-main/src/features/settings/version/services/versionService.ts
|
||||
- IMPLEMENT: Service object with API methods
|
||||
- FOLLOW pattern: projectService.ts using callAPIWithETag
|
||||
- NAMING: checkVersion() async method
|
||||
- DEPENDENCIES: Import callAPIWithETag from shared
|
||||
- PLACEMENT: Version feature services directory
|
||||
|
||||
Task 10: CREATE archon-ui-main/src/features/settings/version/hooks/useVersionQueries.ts
|
||||
- IMPLEMENT: TanStack Query hooks with query keys factory
|
||||
- FOLLOW pattern: useProjectQueries.ts for query patterns
|
||||
- NAMING: versionKeys factory, useVersionCheck() hook
|
||||
- DEPENDENCIES: Import from @tanstack/react-query, shared patterns
|
||||
- PLACEMENT: Version feature hooks directory
|
||||
|
||||
Task 11: CREATE archon-ui-main/src/features/settings/version/components/UpdateBanner.tsx
|
||||
- IMPLEMENT: Banner component showing update availability
|
||||
- FOLLOW pattern: Existing UI components with Tailwind styling
|
||||
- NAMING: UpdateBanner component
|
||||
- DEPENDENCIES: Use version hooks, Lucide icons
|
||||
- PLACEMENT: Version feature components directory
|
||||
|
||||
Task 12: CREATE archon-ui-main/src/features/settings/migrations/types/index.ts
|
||||
- IMPLEMENT: Migration types (MigrationRecord, PendingMigration)
|
||||
- FOLLOW pattern: Type definition patterns
|
||||
- PLACEMENT: Migrations feature types directory
|
||||
|
||||
Task 13: CREATE archon-ui-main/src/features/settings/migrations/services/migrationService.ts
|
||||
- IMPLEMENT: API service for migration endpoints
|
||||
- FOLLOW pattern: Service object pattern
|
||||
- NAMING: getMigrationStatus(), getMigrationHistory()
|
||||
- PLACEMENT: Migrations feature services directory
|
||||
|
||||
Task 14: CREATE archon-ui-main/src/features/settings/migrations/hooks/useMigrationQueries.ts
|
||||
- IMPLEMENT: Migration query hooks
|
||||
- FOLLOW pattern: Query hooks with smart polling
|
||||
- NAMING: migrationKeys factory, useMigrationStatus()
|
||||
- PLACEMENT: Migrations feature hooks directory
|
||||
|
||||
Task 15: CREATE archon-ui-main/src/features/settings/migrations/components/PendingMigrationsModal.tsx
|
||||
- IMPLEMENT: Modal showing pending migrations with copy buttons
|
||||
- FOLLOW pattern: Modal components with framer-motion
|
||||
- CRITICAL: Use copyToClipboard from features/shared/utils/clipboard.ts
|
||||
- REFERENCE: See ProjectCardActions.tsx for usage pattern with showToast
|
||||
- PLACEMENT: Migrations feature components directory
|
||||
|
||||
Task 16: MODIFY archon-ui-main/src/pages/SettingsPage.tsx
|
||||
- INTEGRATE: Import and render new version/migration components
|
||||
- ADD: UpdateBanner at top if update available
|
||||
- ADD: Migration status section with pending count
|
||||
- PRESERVE: Existing settings sections
|
||||
```
|
||||
|
||||
### Implementation Patterns & Key Details
|
||||
|
||||
```python
|
||||
# Version Service Pattern - Caching with TTL
|
||||
class VersionService:
|
||||
def __init__(self):
|
||||
self._cache: dict | None = None
|
||||
self._cache_time: datetime | None = None
|
||||
self._cache_ttl = 3600 # 1 hour
|
||||
|
||||
async def get_latest_version(self) -> dict:
|
||||
# PATTERN: Check cache first (follow credential_service.py caching)
|
||||
if self._is_cache_valid():
|
||||
return self._cache
|
||||
|
||||
# GOTCHA: GitHub API returns 404 for repos with no releases
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=1.0) as client:
|
||||
response = await client.get(
|
||||
"https://api.github.com/repos/coleam00/Archon/releases/latest",
|
||||
headers={"Accept": "application/vnd.github.v3+json"}
|
||||
)
|
||||
if response.status_code == 404:
|
||||
# No releases yet
|
||||
return {"update_available": False, "latest": None}
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
self._cache = data
|
||||
self._cache_time = datetime.now()
|
||||
return data
|
||||
except Exception as e:
|
||||
# CRITICAL: Return cached data or safe default on failure
|
||||
if self._cache:
|
||||
return self._cache
|
||||
return {"error": str(e), "update_available": False}
|
||||
|
||||
# Migration Service Pattern - Bootstrap handling
|
||||
class MigrationService:
|
||||
async def check_migrations_table_exists(self) -> bool:
|
||||
# PATTERN: Check table existence before querying
|
||||
supabase = self._get_supabase_client()
|
||||
try:
|
||||
# Query information_schema to check if table exists
|
||||
result = supabase.rpc(
|
||||
"check_table_exists",
|
||||
{"table_name": "archon_migrations"}
|
||||
).execute()
|
||||
return result.data
|
||||
except:
|
||||
# Assume table doesn't exist if query fails
|
||||
return False
|
||||
|
||||
async def get_pending_migrations(self) -> list[PendingMigration]:
|
||||
# CRITICAL: Check table exists first
|
||||
if not await self.check_migrations_table_exists():
|
||||
# Bootstrap case - all migrations are pending
|
||||
return await self.get_all_filesystem_migrations()
|
||||
|
||||
# Normal case - compare filesystem vs database
|
||||
all_migrations = await self.scan_migration_directory()
|
||||
applied = await self.get_applied_migrations()
|
||||
# ... comparison logic
|
||||
|
||||
# Frontend Pattern - Smart Polling with Visibility
|
||||
export function useVersionCheck() {
|
||||
const { refetchInterval } = useSmartPolling(30000); // 30 seconds base
|
||||
|
||||
return useQuery({
|
||||
queryKey: versionKeys.check(),
|
||||
queryFn: () => versionService.checkVersion(),
|
||||
staleTime: STALE_TIMES.rare, // 5 minutes
|
||||
refetchInterval, // Pauses when tab hidden
|
||||
retry: false, // Don't retry on 404
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
```yaml
|
||||
DATABASE:
|
||||
- migration: "005_add_migration_tracking.sql creates archon_migrations table"
|
||||
- index: "CREATE INDEX idx_archon_migrations_version ON archon_migrations(version)"
|
||||
|
||||
CONFIG:
|
||||
- add to: python/src/server/config/version.py
|
||||
- pattern: 'ARCHON_VERSION = "0.1.0"'
|
||||
|
||||
ROUTES:
|
||||
- add to: python/src/server/main.py
|
||||
- pattern: |
|
||||
from .api_routes import version_api, migration_api
|
||||
app.include_router(version_api.router)
|
||||
app.include_router(migration_api.router)
|
||||
|
||||
FRONTEND:
|
||||
- modify: archon-ui-main/src/pages/SettingsPage.tsx
|
||||
- imports: "import { UpdateBanner } from '../features/settings/version/components'"
|
||||
- render: "Add <UpdateBanner /> component at top of page"
|
||||
```
|
||||
|
||||
## Validation Loop
|
||||
|
||||
### Level 1: Syntax & Style (Immediate Feedback)
|
||||
|
||||
```bash
|
||||
# Backend validation
|
||||
cd python
|
||||
ruff check src/server/api_routes/version_api.py src/server/api_routes/migration_api.py --fix
|
||||
ruff check src/server/services/version_service.py src/server/services/migration_service.py --fix
|
||||
mypy src/server/api_routes/ src/server/services/
|
||||
|
||||
# Frontend validation
|
||||
cd archon-ui-main
|
||||
npm run biome:fix src/features/settings/
|
||||
npx tsc --noEmit 2>&1 | grep "src/features/settings"
|
||||
|
||||
# Expected: Zero errors. Fix any issues before proceeding.
|
||||
```
|
||||
|
||||
### Level 2: Unit Tests (Component Validation)
|
||||
|
||||
```bash
|
||||
# Backend tests to create
|
||||
cd python
|
||||
|
||||
# Test version service:
|
||||
# - Test GitHub API 404 handling returns update_available: false
|
||||
# - Test cache TTL expiration and refresh
|
||||
# - Test version comparison logic (0.1.0 vs 1.0.0, v-prefix handling)
|
||||
# - Test fallback to cached data on network failure
|
||||
uv run pytest tests/server/services/test_version_service.py -v
|
||||
|
||||
# Test migration service:
|
||||
# - Test filesystem scanning finds all .sql files
|
||||
# - Test bootstrap case when table doesn't exist
|
||||
# - Test pending vs applied migration comparison
|
||||
# - Test checksum calculation for migration files
|
||||
uv run pytest tests/server/services/test_migration_service.py -v
|
||||
```
|
||||
|
||||
## Final Validation Checklist
|
||||
|
||||
### Technical Validation
|
||||
|
||||
- [ ] Both validation levels completed successfully
|
||||
- [ ] Backend tests pass: `uv run pytest tests/ -v`
|
||||
- [ ] No linting errors: `ruff check src/`
|
||||
- [ ] No type errors: `mypy src/`
|
||||
- [ ] Frontend builds: `npm run build`
|
||||
|
||||
### Feature Validation
|
||||
|
||||
- [ ] Version check shows current vs latest correctly
|
||||
- [ ] Update banner appears when new version available
|
||||
- [ ] Migration status shows accurate pending count
|
||||
- [ ] Pending migrations modal displays SQL content
|
||||
- [ ] Copy buttons work for migration SQL
|
||||
- [ ] Refresh updates migration status after applying
|
||||
- [ ] Bootstrap case creates migrations table
|
||||
- [ ] GitHub API failures handled gracefully
|
||||
|
||||
### Code Quality Validation
|
||||
|
||||
- [ ] Follows existing FastAPI patterns for routes
|
||||
- [ ] Uses service layer pattern consistently
|
||||
- [ ] Frontend follows vertical slice architecture
|
||||
- [ ] TanStack Query patterns properly implemented
|
||||
- [ ] Error handling matches existing patterns
|
||||
- [ ] ETag support integrated where appropriate
|
||||
|
||||
### Documentation & Deployment
|
||||
|
||||
- [ ] Version constant documented for release process
|
||||
- [ ] Migration self-recording pattern documented
|
||||
- [ ] Environment variables documented if added
|
||||
- [ ] CONTRIBUTING.md updated with migration guidelines
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- ❌ Don't try to execute migrations programmatically (Supabase limitation)
|
||||
- ❌ Don't poll GitHub API without caching (rate limits)
|
||||
- ❌ Don't assume migrations table exists (bootstrap case)
|
||||
- ❌ Don't hardcode version strings outside of config/version.py
|
||||
- ❌ Don't skip self-recording INSERT in migrations
|
||||
- ❌ Don't use synchronous HTTP calls in async functions
|
||||
- ❌ Don't put new UI components in legacy components/ directory
|
||||
342
PRPs/version-and-migration-system.md
Normal file
342
PRPs/version-and-migration-system.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Version Checking and Migration Tracking System Implementation
|
||||
|
||||
## Overview
|
||||
Implement a comprehensive version checking and migration tracking system for Archon to help users stay up-to-date and manage database schema changes when upgrading.
|
||||
|
||||
## 1. Version Checking API
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
#### 1.1 Create Version Check Endpoint
|
||||
**File**: `python/src/server/api_routes/version_api.py`
|
||||
|
||||
Create a new API router with the following endpoint:
|
||||
|
||||
```python
|
||||
GET /api/version/check
|
||||
```
|
||||
|
||||
**Implementation Requirements:**
|
||||
- Call GitHub API: `https://api.github.com/repos/coleam00/Archon/releases/latest`
|
||||
- Handle case where no releases exist yet (404 response) - return `update_available: false`
|
||||
- Compare with current Archon version (stored in a constants file that you will need to create too)
|
||||
- Return response format:
|
||||
```json
|
||||
{
|
||||
"current": "1.0.0",
|
||||
"latest": "1.1.0",
|
||||
"update_available": true,
|
||||
"release_url": "https://github.com/coleam00/Archon/releases/tag/v1.1.0",
|
||||
"release_notes": "Release notes from GitHub...",
|
||||
"published_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
```
|
||||
- Use 1-second timeout for GitHub API call
|
||||
- Cache response for 1 hour to avoid rate limiting (use simple in-memory cache)
|
||||
- Graceful fallback: return current version as latest on error or if no releases exist
|
||||
- Add environment variable `ARCHON_VERSION_CHECK_ENABLED` (default: `true`)
|
||||
- If disabled, on error, or no releases exist, return `update_available: false`
|
||||
|
||||
#### 1.2 Version Configuration
|
||||
**File**: `python/src/server/config/version.py`
|
||||
|
||||
Create version configuration:
|
||||
```python
|
||||
ARCHON_VERSION = "1.0.0" # Update this with each release
|
||||
```
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
#### 1.3 Version Query Hook
|
||||
**File**: `archon-ui-main/src/features/settings/hooks/useVersionQueries.ts`
|
||||
|
||||
Create TanStack Query hooks:
|
||||
- `useVersionCheck()` - Query version endpoint
|
||||
- Use `STALE_TIMES.rare` (5 minutes) for caching
|
||||
- Only check when settings page is open
|
||||
|
||||
#### 1.4 Version Service
|
||||
**File**: `archon-ui-main/src/features/settings/services/versionService.ts`
|
||||
|
||||
Create service for API calls:
|
||||
```typescript
|
||||
export const versionService = {
|
||||
async checkVersion(): Promise<VersionCheckResponse> {
|
||||
// Call /api/version/check
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 Update Banner Component
|
||||
**File**: `archon-ui-main/src/features/settings/components/UpdateBanner.tsx`
|
||||
|
||||
Create update notification banner:
|
||||
- Show at top of settings page when update available
|
||||
- Display current vs latest version
|
||||
- Link to GitHub release page
|
||||
- Include "View Upgrade Instructions" button that opens modal
|
||||
|
||||
#### 1.6 Upgrade Instructions Modal
|
||||
**File**: `archon-ui-main/src/features/settings/components/UpgradeInstructionsModal.tsx`
|
||||
|
||||
Show upgrade instructions:
|
||||
- Display standard upgrade steps:
|
||||
1. Pull latest changes: `git pull`
|
||||
2. Check for pending migrations (link to migrations section)
|
||||
3. Rebuild and restart: `docker compose up -d --build`
|
||||
- Link to GitHub release notes
|
||||
- Warning about checking migrations before upgrading
|
||||
|
||||
## 2. Migration Tracking System
|
||||
|
||||
### Database Schema
|
||||
|
||||
#### 2.1 Create Migrations Table
|
||||
**File**: Add to next migration SQL file (e.g., `migration/0.1.0/005_add_migration_tracking.sql`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS archon_migrations (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
migration_name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(version, migration_name)
|
||||
);
|
||||
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX idx_migrations_version ON archon_migrations(version);
|
||||
|
||||
-- Record this migration as applied
|
||||
INSERT INTO archon_migrations (version, migration_name)
|
||||
VALUES ('0.1.0', '005_add_migration_tracking')
|
||||
ON CONFLICT (version, migration_name) DO NOTHING;
|
||||
```
|
||||
|
||||
### Migration File Structure
|
||||
|
||||
#### 2.2 Migration Organization
|
||||
**Directory Structure (Example, not actual versions we have)**:
|
||||
```
|
||||
migration/
|
||||
├── 1.0.0/
|
||||
│ └── 001_initial_schema.sql
|
||||
├── 1.1.0/
|
||||
│ ├── 001_add_migration_tracking.sql
|
||||
│ └── 002_add_new_feature.sql
|
||||
└── 1.2.0/
|
||||
└── 001_update_columns.sql
|
||||
```
|
||||
|
||||
**Important**: Each migration SQL file must include an INSERT statement at the end to record itself in the `archon_migrations` table (see example in 2.1).
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
#### 2.4 Migration Service
|
||||
**File**: `python/src/server/services/migration_service.py`
|
||||
|
||||
Implement migration tracking:
|
||||
- `get_all_migrations()` - Scan `migration/` folder for all SQL files
|
||||
- `get_applied_migrations()` - Query archon_migrations table (handle case where table doesn't exist)
|
||||
- `get_pending_migrations()` - Compare filesystem vs database
|
||||
- `check_migrations_table_exists()` - Check if archon_migrations table exists (bootstrap case)
|
||||
|
||||
#### 2.5 Migration API Endpoints
|
||||
**File**: `python/src/server/api_routes/migration_api.py`
|
||||
|
||||
Create endpoints:
|
||||
|
||||
```python
|
||||
GET /api/migrations/status
|
||||
```
|
||||
Returns:
|
||||
```json
|
||||
{
|
||||
"pending_migrations": [
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"name": "001_add_new_table",
|
||||
"sql_content": "CREATE TABLE...",
|
||||
"file_path": "1.1.0/001_add_new_table.sql"
|
||||
}
|
||||
],
|
||||
"applied_migrations": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "001_initial_schema",
|
||||
"applied_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_pending": true
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
GET /api/migrations/history
|
||||
```
|
||||
Returns list of all applied migrations with timestamps
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
#### 2.6 Migration Query Hooks
|
||||
**File**: `archon-ui-main/src/features/settings/hooks/useMigrationQueries.ts`
|
||||
|
||||
Create hooks:
|
||||
- `useMigrationStatus()` - Get pending/applied migrations
|
||||
- `useMigrationHistory()` - Get migration history
|
||||
|
||||
#### 2.7 Migration Service
|
||||
**File**: `archon-ui-main/src/features/settings/services/migrationService.ts`
|
||||
|
||||
API service methods for migration endpoints
|
||||
|
||||
#### 2.8 Pending Migrations Modal
|
||||
**File**: `archon-ui-main/src/features/settings/components/PendingMigrationsModal.tsx`
|
||||
|
||||
Display pending migrations:
|
||||
- Show alert badge on settings page when migrations pending
|
||||
- Modal with list of pending migrations
|
||||
- Each migration shows:
|
||||
- Version number
|
||||
- Migration name
|
||||
- Expandable SQL content (with copy button)
|
||||
- Instructions:
|
||||
1. Copy the SQL script
|
||||
2. Go to Supabase SQL Editor
|
||||
3. Run the script (which will automatically record it as applied)
|
||||
4. Click "Refresh Status" to update the UI
|
||||
- "Refresh Status" button to re-query migration status
|
||||
- Auto-refresh status every few seconds while modal is open
|
||||
|
||||
#### 2.9 Migration Settings Section
|
||||
**File**: Update existing settings page
|
||||
|
||||
Add migrations section showing:
|
||||
- Current migration status (X pending, Y applied)
|
||||
- "View Pending Migrations" button (opens modal)
|
||||
- "View Migration History" link
|
||||
- Last migration applied timestamp
|
||||
|
||||
## 3. Integration Points
|
||||
|
||||
### 3.1 Settings Page Updates
|
||||
**File**: `archon-ui-main/src/pages/SettingsPage.tsx`
|
||||
|
||||
Add new sections:
|
||||
- Version information section with update banner
|
||||
- Migration status section with pending count
|
||||
|
||||
### 3.2 App Initialization Check
|
||||
**File**: `archon-ui-main/src/App.tsx` or main layout
|
||||
|
||||
On app load:
|
||||
- Check for pending migrations
|
||||
- If migrations pending, show non-dismissible alert banner
|
||||
- Banner links to settings page migrations section
|
||||
|
||||
## 4. Environment Variables
|
||||
|
||||
Add to `.env.example`:
|
||||
```bash
|
||||
# Version checking (optional)
|
||||
ARCHON_VERSION_CHECK_ENABLED=true # Set to false to disable version checking
|
||||
```
|
||||
|
||||
## 5. Testing Requirements
|
||||
|
||||
### Backend Tests
|
||||
- Mock GitHub API responses (research exact API response format for this)
|
||||
- Test version comparison logic
|
||||
- Test migration file scanning
|
||||
- Test migration status comparison
|
||||
|
||||
### Frontend Tests
|
||||
- Test version check display
|
||||
- Test migration modal functionality
|
||||
- Test migration status refresh workflow
|
||||
|
||||
## 6. Implementation Order
|
||||
|
||||
1. **Phase 1 - Version Checking**:
|
||||
- Backend version API
|
||||
- Frontend version checking components
|
||||
- Settings page integration
|
||||
|
||||
2. **Phase 2 - Migration Infrastructure**:
|
||||
- Database table creation
|
||||
- Migration file structure
|
||||
|
||||
3. **Phase 3 - Migration Tracking**:
|
||||
- Backend migration service and API
|
||||
- Frontend migration components
|
||||
- Settings page migration section
|
||||
|
||||
4. **Phase 4 - Polish**:
|
||||
- App initialization checks
|
||||
- Alert banners
|
||||
- Testing
|
||||
|
||||
## 7. Future Enhancements (Not for Initial Implementation)
|
||||
|
||||
- Automatic migration runner for PostgreSQL (when we move from Supabase)
|
||||
- Migration rollback tracking
|
||||
- Version-specific upgrade guides
|
||||
- Pre-upgrade validation checks
|
||||
- Migration dry-run capability
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
### Important Constraints
|
||||
1. **Supabase Limitation**: We cannot run SQL migrations programmatically due to Supabase SDK restrictions. Users must manually run SQL in Supabase SQL Editor.
|
||||
2. **Simplicity**: Keep UI simple and focused. No unnecessary toasts or complex workflows.
|
||||
3. **Beta Consideration**: Since Archon is in beta, version checking should be enabled by default to help users stay current.
|
||||
|
||||
### Key Design Decisions
|
||||
1. **Direct GitHub API**: Use GitHub releases API directly instead of maintaining separate version service
|
||||
2. **Self-Recording Migrations**: Each migration SQL includes INSERT to record itself when run
|
||||
3. **Bootstrap Handling**: If migrations table doesn't exist, that's the first pending migration
|
||||
4. **Simple Tracking**: If migration is in database, it's been run. No complex status tracking.
|
||||
5. **Non-intrusive Updates**: Version checks only on settings page, not constant polling
|
||||
|
||||
### Success Criteria
|
||||
- Users can see when new version is available
|
||||
- Users can easily see pending migrations
|
||||
- Users can copy migration SQL which self-records when run
|
||||
- System tracks migration history accurately
|
||||
- Handles bootstrap case when migrations table doesn't exist
|
||||
- Minimal performance impact on app startup
|
||||
|
||||
## 8. Contributing.md Addition
|
||||
|
||||
**File**: Add to `CONTRIBUTING.md`
|
||||
|
||||
Add the following section:
|
||||
|
||||
### Creating Database Migrations
|
||||
|
||||
When adding database schema changes:
|
||||
|
||||
1. Create a new migration file in the appropriate version folder:
|
||||
```
|
||||
migration/{version}/XXX_description.sql
|
||||
```
|
||||
Where XXX is a three-digit number (e.g., 001, 002, 003)
|
||||
|
||||
2. **IMPORTANT**: Every migration must end with an INSERT statement to record itself:
|
||||
```sql
|
||||
-- Your migration SQL here
|
||||
CREATE TABLE example_table (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- REQUIRED: Record this migration
|
||||
INSERT INTO archon_migrations (version, migration_name)
|
||||
VALUES ('{version}', 'XXX_description')
|
||||
ON CONFLICT (version, migration_name) DO NOTHING;
|
||||
```
|
||||
|
||||
3. The INSERT ensures the migration is tracked after successful execution
|
||||
4. Use `ON CONFLICT DO NOTHING` to make migrations idempotent
|
||||
5. Replace `{version}` with the actual version number (e.g., '0.1.0')
|
||||
Replace `XXX_description` with the actual migration filename (e.g., '001_add_new_table')
|
||||
6. Migration names should be descriptive but concise
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Card component showing migration status
|
||||
*/
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertTriangle, CheckCircle, Database, RefreshCw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useMigrationStatus } from "../hooks/useMigrationQueries";
|
||||
import { PendingMigrationsModal } from "./PendingMigrationsModal";
|
||||
|
||||
export function MigrationStatusCard() {
|
||||
const { data, isLoading, error, refetch } = useMigrationStatus();
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="bg-gray-900/50 border border-gray-700 rounded-lg p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-white font-semibold">Database Migrations</h3>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Refresh migration status"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-gray-400 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Applied Migrations</span>
|
||||
<span className="text-white font-mono text-sm">{data?.applied_count ?? 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Pending Migrations</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-mono text-sm">{data?.pending_count ?? 0}</span>
|
||||
{data && data.pending_count > 0 && <AlertTriangle className="w-4 h-4 text-yellow-400" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Status</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 text-blue-400 animate-spin" />
|
||||
<span className="text-blue-400 text-sm">Checking...</span>
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-red-400 text-sm">Error loading</span>
|
||||
</>
|
||||
) : data?.bootstrap_required ? (
|
||||
<>
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-yellow-400 text-sm">Setup required</span>
|
||||
</>
|
||||
) : data?.has_pending ? (
|
||||
<>
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-yellow-400 text-sm">Migrations pending</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400 text-sm">Up to date</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.current_version && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Database Version</span>
|
||||
<span className="text-white font-mono text-sm">{data.current_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data?.has_pending && (
|
||||
<div className="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<p className="text-yellow-400 text-sm mb-2">
|
||||
{data.bootstrap_required
|
||||
? "Initial database setup is required."
|
||||
: `${data.pending_count} migration${data.pending_count > 1 ? "s" : ""} need to be applied.`}
|
||||
</p>
|
||||
<button type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/50 rounded text-yellow-400 text-sm font-medium transition-colors"
|
||||
>
|
||||
View Pending Migrations
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">
|
||||
Failed to load migration status. Please check your database connection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Modal for viewing pending migrations */}
|
||||
{data && (
|
||||
<PendingMigrationsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
migrations={data.pending_migrations}
|
||||
onMigrationsApplied={refetch}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Modal for viewing and copying pending migration SQL
|
||||
*/
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckCircle, Copy, Database, ExternalLink, X } from "lucide-react";
|
||||
import React from "react";
|
||||
import { copyToClipboard } from "@/features/shared/utils/clipboard";
|
||||
import { useToast } from "@/features/ui/hooks/useToast";
|
||||
import type { PendingMigration } from "../types";
|
||||
|
||||
interface PendingMigrationsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
migrations: PendingMigration[];
|
||||
onMigrationsApplied: () => void;
|
||||
}
|
||||
|
||||
export function PendingMigrationsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
migrations,
|
||||
onMigrationsApplied,
|
||||
}: PendingMigrationsModalProps) {
|
||||
const { showToast } = useToast();
|
||||
const [copiedIndex, setCopiedIndex] = React.useState<number | null>(null);
|
||||
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(null);
|
||||
|
||||
const handleCopy = async (sql: string, index: number) => {
|
||||
const result = await copyToClipboard(sql);
|
||||
if (result.success) {
|
||||
setCopiedIndex(index);
|
||||
showToast("SQL copied to clipboard", "success");
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} else {
|
||||
showToast("Failed to copy SQL", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyAll = async () => {
|
||||
const allSql = migrations.map((m) => `-- ${m.name}\n${m.sql_content}`).join("\n\n");
|
||||
const result = await copyToClipboard(allSql);
|
||||
if (result.success) {
|
||||
showToast("All migration SQL copied to clipboard", "success");
|
||||
} else {
|
||||
showToast("Failed to copy SQL", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-6 h-6 text-purple-400" />
|
||||
<h2 className="text-xl font-semibold text-white">Pending Database Migrations</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-6 bg-blue-500/10 border-b border-gray-700">
|
||||
<h3 className="text-blue-400 font-medium mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
How to Apply Migrations
|
||||
</h3>
|
||||
<ol className="text-sm text-gray-300 space-y-1 list-decimal list-inside">
|
||||
<li>Copy the SQL for each migration below</li>
|
||||
<li>Open your Supabase dashboard SQL Editor</li>
|
||||
<li>Paste and execute each migration in order</li>
|
||||
<li>Click "Refresh Status" below to verify migrations were applied</li>
|
||||
</ol>
|
||||
{migrations.length > 1 && (
|
||||
<button type="button"
|
||||
onClick={handleCopyAll}
|
||||
className="mt-3 px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 border border-blue-500/50 rounded text-blue-400 text-sm font-medium transition-colors"
|
||||
>
|
||||
Copy All Migrations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Migration List */}
|
||||
<div className="overflow-y-auto max-h-[calc(80vh-280px)] p-6 pb-8">
|
||||
{migrations.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-gray-300">All migrations have been applied!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pb-4">
|
||||
{migrations.map((migration, index) => (
|
||||
<div
|
||||
key={`${migration.version}-${migration.name}`}
|
||||
className="bg-gray-800/50 border border-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="text-white font-medium">{migration.name}</h4>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Version: {migration.version} • {migration.file_path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => handleCopy(migration.sql_content, index)}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy SQL
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setExpandedIndex(expandedIndex === index ? null : index)}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300 transition-colors"
|
||||
>
|
||||
{expandedIndex === index ? "Hide" : "Show"} SQL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL Content */}
|
||||
<AnimatePresence>
|
||||
{expandedIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<pre className="mt-3 p-3 bg-gray-900 border border-gray-700 rounded text-xs text-gray-300 overflow-x-auto">
|
||||
<code>{migration.sql_content}</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-700 flex justify-between">
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onMigrationsApplied}
|
||||
className="px-4 py-2 bg-purple-500/20 hover:bg-purple-500/30 border border-purple-500/50 rounded-lg text-purple-400 font-medium transition-colors"
|
||||
>
|
||||
Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* TanStack Query hooks for migration tracking
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { STALE_TIMES } from "@/features/shared/queryPatterns";
|
||||
import { useSmartPolling } from "@/features/ui/hooks/useSmartPolling";
|
||||
import { migrationService } from "../services/migrationService";
|
||||
import type { MigrationHistoryResponse, MigrationStatusResponse, PendingMigration } from "../types";
|
||||
|
||||
// Query key factory
|
||||
export const migrationKeys = {
|
||||
all: ["migrations"] as const,
|
||||
status: () => [...migrationKeys.all, "status"] as const,
|
||||
history: () => [...migrationKeys.all, "history"] as const,
|
||||
pending: () => [...migrationKeys.all, "pending"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get comprehensive migration status
|
||||
* Polls more frequently when migrations are pending
|
||||
*/
|
||||
export function useMigrationStatus() {
|
||||
// Poll every 30 seconds when tab is visible
|
||||
const { refetchInterval } = useSmartPolling(30000);
|
||||
|
||||
return useQuery<MigrationStatusResponse>({
|
||||
queryKey: migrationKeys.status(),
|
||||
queryFn: () => migrationService.getMigrationStatus(),
|
||||
staleTime: STALE_TIMES.normal, // 30 seconds
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get migration history
|
||||
*/
|
||||
export function useMigrationHistory() {
|
||||
return useQuery<MigrationHistoryResponse>({
|
||||
queryKey: migrationKeys.history(),
|
||||
queryFn: () => migrationService.getMigrationHistory(),
|
||||
staleTime: STALE_TIMES.rare, // 5 minutes - history doesn't change often
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get pending migrations only
|
||||
*/
|
||||
export function usePendingMigrations() {
|
||||
const { refetchInterval } = useSmartPolling(30000);
|
||||
|
||||
return useQuery<PendingMigration[]>({
|
||||
queryKey: migrationKeys.pending(),
|
||||
queryFn: () => migrationService.getPendingMigrations(),
|
||||
staleTime: STALE_TIMES.normal,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Service for database migration tracking and management
|
||||
*/
|
||||
|
||||
import { callAPIWithETag } from "@/features/shared/apiWithEtag";
|
||||
import type { MigrationHistoryResponse, MigrationStatusResponse, PendingMigration } from "../types";
|
||||
|
||||
export const migrationService = {
|
||||
/**
|
||||
* Get comprehensive migration status including pending and applied
|
||||
*/
|
||||
async getMigrationStatus(): Promise<MigrationStatusResponse> {
|
||||
try {
|
||||
const response = await callAPIWithETag("/api/migrations/status");
|
||||
return response as MigrationStatusResponse;
|
||||
} catch (error) {
|
||||
console.error("Error getting migration status:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get history of applied migrations
|
||||
*/
|
||||
async getMigrationHistory(): Promise<MigrationHistoryResponse> {
|
||||
try {
|
||||
const response = await callAPIWithETag("/api/migrations/history");
|
||||
return response as MigrationHistoryResponse;
|
||||
} catch (error) {
|
||||
console.error("Error getting migration history:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of pending migrations only
|
||||
*/
|
||||
async getPendingMigrations(): Promise<PendingMigration[]> {
|
||||
try {
|
||||
const response = await callAPIWithETag("/api/migrations/pending");
|
||||
return response as PendingMigration[];
|
||||
} catch (error) {
|
||||
console.error("Error getting pending migrations:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Type definitions for database migration tracking and management
|
||||
*/
|
||||
|
||||
export interface MigrationRecord {
|
||||
version: string;
|
||||
migration_name: string;
|
||||
applied_at: string;
|
||||
checksum?: string | null;
|
||||
}
|
||||
|
||||
export interface PendingMigration {
|
||||
version: string;
|
||||
name: string;
|
||||
sql_content: string;
|
||||
file_path: string;
|
||||
checksum?: string | null;
|
||||
}
|
||||
|
||||
export interface MigrationStatusResponse {
|
||||
pending_migrations: PendingMigration[];
|
||||
applied_migrations: MigrationRecord[];
|
||||
has_pending: boolean;
|
||||
bootstrap_required: boolean;
|
||||
current_version: string;
|
||||
pending_count: number;
|
||||
applied_count: number;
|
||||
}
|
||||
|
||||
export interface MigrationHistoryResponse {
|
||||
migrations: MigrationRecord[];
|
||||
total_count: number;
|
||||
current_version: string;
|
||||
}
|
||||
|
||||
export interface MigrationState {
|
||||
status: MigrationStatusResponse | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
selectedMigration: PendingMigration | null;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Banner component that shows when an update is available
|
||||
*/
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowUpCircle, ExternalLink, X } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useVersionCheck } from "../hooks/useVersionQueries";
|
||||
|
||||
export function UpdateBanner() {
|
||||
const { data, isLoading, error } = useVersionCheck();
|
||||
const [isDismissed, setIsDismissed] = React.useState(false);
|
||||
|
||||
// Don't show banner if loading, error, no data, or no update available
|
||||
if (isLoading || error || !data?.update_available || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowUpCircle className="w-6 h-6 text-blue-400 animate-pulse" />
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Update Available: v{data.latest}</h3>
|
||||
<p className="text-gray-400 text-sm mt-1">You are currently running v{data.current}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{data.release_url && (
|
||||
<a
|
||||
href={data.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 border border-blue-500/50 rounded-lg text-blue-400 transition-all duration-200"
|
||||
>
|
||||
<span className="text-sm font-medium">View Release</span>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors"
|
||||
aria-label="Dismiss update banner"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Card component showing current version status
|
||||
*/
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertCircle, CheckCircle, Info, RefreshCw } from "lucide-react";
|
||||
import { useClearVersionCache, useVersionCheck } from "../hooks/useVersionQueries";
|
||||
|
||||
export function VersionStatusCard() {
|
||||
const { data, isLoading, error, refetch } = useVersionCheck();
|
||||
const clearCache = useClearVersionCache();
|
||||
|
||||
const handleRefreshClick = async () => {
|
||||
// Clear cache and then refetch
|
||||
await clearCache.mutateAsync();
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-gray-900/50 border border-gray-700 rounded-lg p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Info className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="text-white font-semibold">Version Information</h3>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={handleRefreshClick}
|
||||
disabled={isLoading || clearCache.isPending}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Refresh version check"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-gray-400 ${isLoading || clearCache.isPending ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Current Version</span>
|
||||
<span className="text-white font-mono text-sm">{data?.current || "Loading..."}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Latest Version</span>
|
||||
<span className="text-white font-mono text-sm">
|
||||
{isLoading ? "Checking..." : error ? "Check failed" : data?.latest ? data.latest : "No releases found"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Status</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 text-blue-400 animate-spin" />
|
||||
<span className="text-blue-400 text-sm">Checking...</span>
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-red-400 text-sm">Error checking</span>
|
||||
</>
|
||||
) : data?.update_available ? (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-yellow-400 text-sm">Update available</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400 text-sm">Up to date</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.published_at && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">Released</span>
|
||||
<span className="text-gray-300 text-sm">{new Date(data.published_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">
|
||||
{data?.check_error || "Failed to check for updates. Please try again later."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* TanStack Query hooks for version checking
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { STALE_TIMES } from "@/features/shared/queryPatterns";
|
||||
import { useSmartPolling } from "@/features/ui/hooks/useSmartPolling";
|
||||
import { versionService } from "../services/versionService";
|
||||
import type { VersionCheckResponse } from "../types";
|
||||
|
||||
// Query key factory
|
||||
export const versionKeys = {
|
||||
all: ["version"] as const,
|
||||
check: () => [...versionKeys.all, "check"] as const,
|
||||
current: () => [...versionKeys.all, "current"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check for version updates
|
||||
* Polls every 5 minutes when tab is visible
|
||||
*/
|
||||
export function useVersionCheck() {
|
||||
// Smart polling: check every 5 minutes when tab is visible
|
||||
const { refetchInterval } = useSmartPolling(300000); // 5 minutes
|
||||
|
||||
return useQuery<VersionCheckResponse>({
|
||||
queryKey: versionKeys.check(),
|
||||
queryFn: () => versionService.checkVersion(),
|
||||
staleTime: STALE_TIMES.rare, // 5 minutes
|
||||
refetchInterval,
|
||||
retry: false, // Don't retry on 404 or network errors
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current version without checking for updates
|
||||
*/
|
||||
export function useCurrentVersion() {
|
||||
return useQuery({
|
||||
queryKey: versionKeys.current(),
|
||||
queryFn: () => versionService.getCurrentVersion(),
|
||||
staleTime: STALE_TIMES.static, // Never stale
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to clear version cache and force fresh check
|
||||
*/
|
||||
export function useClearVersionCache() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => versionService.clearCache(),
|
||||
onSuccess: () => {
|
||||
// Invalidate version queries to force fresh check
|
||||
queryClient.invalidateQueries({ queryKey: versionKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Service for version checking and update management
|
||||
*/
|
||||
|
||||
import { callAPIWithETag } from "@/features/shared/apiWithEtag";
|
||||
import type { CurrentVersionResponse, VersionCheckResponse } from "../types";
|
||||
|
||||
export const versionService = {
|
||||
/**
|
||||
* Check for available Archon updates
|
||||
*/
|
||||
async checkVersion(): Promise<VersionCheckResponse> {
|
||||
try {
|
||||
const response = await callAPIWithETag("/api/version/check");
|
||||
return response as VersionCheckResponse;
|
||||
} catch (error) {
|
||||
console.error("Error checking version:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current Archon version without checking for updates
|
||||
*/
|
||||
async getCurrentVersion(): Promise<CurrentVersionResponse> {
|
||||
try {
|
||||
const response = await callAPIWithETag("/api/version/current");
|
||||
return response as CurrentVersionResponse;
|
||||
} catch (error) {
|
||||
console.error("Error getting current version:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear version cache to force fresh check
|
||||
*/
|
||||
async clearCache(): Promise<{ message: string; success: boolean }> {
|
||||
try {
|
||||
const response = await callAPIWithETag("/api/version/clear-cache", {
|
||||
method: "POST",
|
||||
});
|
||||
return response as { message: string; success: boolean };
|
||||
} catch (error) {
|
||||
console.error("Error clearing version cache:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
35
archon-ui-main/src/features/settings/version/types/index.ts
Normal file
35
archon-ui-main/src/features/settings/version/types/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Type definitions for version checking and update management
|
||||
*/
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface VersionCheckResponse {
|
||||
current: string;
|
||||
latest: string | null;
|
||||
update_available: boolean;
|
||||
release_url: string | null;
|
||||
release_notes: string | null;
|
||||
published_at: string | null;
|
||||
check_error?: string | null;
|
||||
assets?: ReleaseAsset[] | null;
|
||||
author?: string | null;
|
||||
}
|
||||
|
||||
export interface CurrentVersionResponse {
|
||||
version: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface VersionStatus {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
data: VersionCheckResponse | null;
|
||||
lastChecked: Date | null;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Code,
|
||||
FileCode,
|
||||
Bug,
|
||||
Info,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useToast } from "../features/ui/hooks/useToast";
|
||||
@@ -28,6 +30,9 @@ import {
|
||||
RagSettings,
|
||||
CodeExtractionSettings as CodeExtractionSettingsType,
|
||||
} from "../services/credentialsService";
|
||||
import { UpdateBanner } from "../features/settings/version/components/UpdateBanner";
|
||||
import { VersionStatusCard } from "../features/settings/version/components/VersionStatusCard";
|
||||
import { MigrationStatusCard } from "../features/settings/migrations/components/MigrationStatusCard";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const [ragSettings, setRagSettings] = useState<RagSettings>({
|
||||
@@ -106,6 +111,9 @@ export const SettingsPage = () => {
|
||||
variants={containerVariants}
|
||||
className="w-full"
|
||||
>
|
||||
{/* Update Banner */}
|
||||
<UpdateBanner />
|
||||
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex justify-between items-center mb-8"
|
||||
@@ -136,6 +144,33 @@ export const SettingsPage = () => {
|
||||
<FeaturesSection />
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
|
||||
{/* Version Status */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<CollapsibleSettingsCard
|
||||
title="Version & Updates"
|
||||
icon={Info}
|
||||
accentColor="blue"
|
||||
storageKey="version-status"
|
||||
defaultExpanded={true}
|
||||
>
|
||||
<VersionStatusCard />
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
|
||||
{/* Migration Status */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<CollapsibleSettingsCard
|
||||
title="Database Migrations"
|
||||
icon={Database}
|
||||
accentColor="purple"
|
||||
storageKey="migration-status"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<MigrationStatusCard />
|
||||
</CollapsibleSettingsCard>
|
||||
</motion.div>
|
||||
|
||||
{projectsEnabled && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<CollapsibleSettingsCard
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for MCP container control
|
||||
- ./python/src:/app/src # Mount source code for hot reload
|
||||
- ./python/tests:/app/tests # Mount tests for UI test execution
|
||||
- ./migration:/app/migration # Mount migration files for version tracking
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
|
||||
62
migration/0.1.0/005_add_migration_tracking.sql
Normal file
62
migration/0.1.0/005_add_migration_tracking.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- Migration: 005_add_migration_tracking.sql
|
||||
-- Description: Create archon_migrations table for tracking applied database migrations
|
||||
-- Version: 0.1.0
|
||||
-- Author: Archon Team
|
||||
-- Date: 2025
|
||||
|
||||
-- Create archon_migrations table for tracking applied migrations
|
||||
CREATE TABLE IF NOT EXISTS archon_migrations (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
migration_name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
checksum VARCHAR(32),
|
||||
UNIQUE(version, migration_name)
|
||||
);
|
||||
|
||||
-- Add index for fast lookups by version
|
||||
CREATE INDEX IF NOT EXISTS idx_archon_migrations_version ON archon_migrations(version);
|
||||
|
||||
-- Add index for sorting by applied date
|
||||
CREATE INDEX IF NOT EXISTS idx_archon_migrations_applied_at ON archon_migrations(applied_at DESC);
|
||||
|
||||
-- Add comment describing table purpose
|
||||
COMMENT ON TABLE archon_migrations IS 'Tracks database migrations that have been applied to maintain schema version consistency';
|
||||
COMMENT ON COLUMN archon_migrations.version IS 'Archon version that introduced this migration';
|
||||
COMMENT ON COLUMN archon_migrations.migration_name IS 'Filename of the migration SQL file';
|
||||
COMMENT ON COLUMN archon_migrations.applied_at IS 'Timestamp when migration was applied';
|
||||
COMMENT ON COLUMN archon_migrations.checksum IS 'Optional MD5 checksum of migration file content';
|
||||
|
||||
-- Record this migration as applied (self-recording pattern)
|
||||
-- This allows the migration system to bootstrap itself
|
||||
INSERT INTO archon_migrations (version, migration_name)
|
||||
VALUES ('0.1.0', '005_add_migration_tracking')
|
||||
ON CONFLICT (version, migration_name) DO NOTHING;
|
||||
|
||||
-- Retroactively record previously applied migrations (001-004)
|
||||
-- Since these migrations couldn't self-record (table didn't exist yet),
|
||||
-- we record them here to ensure the migration system knows they've been applied
|
||||
INSERT INTO archon_migrations (version, migration_name)
|
||||
VALUES
|
||||
('0.1.0', '001_add_source_url_display_name'),
|
||||
('0.1.0', '002_add_hybrid_search_tsvector'),
|
||||
('0.1.0', '003_ollama_implementation'),
|
||||
('0.1.0', '004_add_priority_column_to_tasks')
|
||||
ON CONFLICT (version, migration_name) DO NOTHING;
|
||||
|
||||
-- Enable Row Level Security on migrations table
|
||||
ALTER TABLE archon_migrations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop existing policies if they exist (makes this idempotent)
|
||||
DROP POLICY IF EXISTS "Allow service role full access to archon_migrations" ON archon_migrations;
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to read archon_migrations" ON archon_migrations;
|
||||
|
||||
-- Create RLS policies for migrations table
|
||||
-- Service role has full access
|
||||
CREATE POLICY "Allow service role full access to archon_migrations" ON archon_migrations
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
-- Authenticated users can only read migrations (they cannot modify migration history)
|
||||
CREATE POLICY "Allow authenticated users to read archon_migrations" ON archon_migrations
|
||||
FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
@@ -63,7 +63,11 @@ BEGIN
|
||||
-- Prompts policies
|
||||
DROP POLICY IF EXISTS "Allow service role full access to archon_prompts" ON archon_prompts;
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to read archon_prompts" ON archon_prompts;
|
||||
|
||||
|
||||
-- Migration tracking policies
|
||||
DROP POLICY IF EXISTS "Allow service role full access to archon_migrations" ON archon_migrations;
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to read archon_migrations" ON archon_migrations;
|
||||
|
||||
-- Legacy table policies (for migration from old schema)
|
||||
DROP POLICY IF EXISTS "Allow service role full access" ON settings;
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to read and update" ON settings;
|
||||
@@ -174,7 +178,10 @@ BEGIN
|
||||
|
||||
-- Configuration System - new archon_ prefixed table
|
||||
DROP TABLE IF EXISTS archon_settings CASCADE;
|
||||
|
||||
|
||||
-- Migration tracking table
|
||||
DROP TABLE IF EXISTS archon_migrations CASCADE;
|
||||
|
||||
-- Legacy tables (without archon_ prefix) - for migration purposes
|
||||
DROP TABLE IF EXISTS document_versions CASCADE;
|
||||
DROP TABLE IF EXISTS project_sources CASCADE;
|
||||
|
||||
@@ -951,6 +951,59 @@ COMMENT ON COLUMN archon_document_versions.change_type IS 'Type of change: creat
|
||||
COMMENT ON COLUMN archon_document_versions.document_id IS 'For docs arrays, the specific document ID that was changed';
|
||||
COMMENT ON COLUMN archon_document_versions.task_id IS 'DEPRECATED: No longer used for new versions, kept for historical task version data';
|
||||
|
||||
-- =====================================================
|
||||
-- SECTION 7: MIGRATION TRACKING
|
||||
-- =====================================================
|
||||
|
||||
-- Create archon_migrations table for tracking applied database migrations
|
||||
CREATE TABLE IF NOT EXISTS archon_migrations (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
migration_name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
checksum VARCHAR(32),
|
||||
UNIQUE(version, migration_name)
|
||||
);
|
||||
|
||||
-- Add indexes for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_archon_migrations_version ON archon_migrations(version);
|
||||
CREATE INDEX IF NOT EXISTS idx_archon_migrations_applied_at ON archon_migrations(applied_at DESC);
|
||||
|
||||
-- Add comments describing table purpose
|
||||
COMMENT ON TABLE archon_migrations IS 'Tracks database migrations that have been applied to maintain schema version consistency';
|
||||
COMMENT ON COLUMN archon_migrations.version IS 'Archon version that introduced this migration';
|
||||
COMMENT ON COLUMN archon_migrations.migration_name IS 'Filename of the migration SQL file';
|
||||
COMMENT ON COLUMN archon_migrations.applied_at IS 'Timestamp when migration was applied';
|
||||
COMMENT ON COLUMN archon_migrations.checksum IS 'Optional MD5 checksum of migration file content';
|
||||
|
||||
-- Record all migrations as applied since this is a complete setup
|
||||
-- This ensures the migration system knows the database is fully up-to-date
|
||||
INSERT INTO archon_migrations (version, migration_name)
|
||||
VALUES
|
||||
('0.1.0', '001_add_source_url_display_name'),
|
||||
('0.1.0', '002_add_hybrid_search_tsvector'),
|
||||
('0.1.0', '003_ollama_implementation'),
|
||||
('0.1.0', '004_add_priority_column_to_tasks'),
|
||||
('0.1.0', '005_add_migration_tracking')
|
||||
ON CONFLICT (version, migration_name) DO NOTHING;
|
||||
|
||||
-- Enable Row Level Security on migrations table
|
||||
ALTER TABLE archon_migrations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop existing policies if they exist (makes this idempotent)
|
||||
DROP POLICY IF EXISTS "Allow service role full access to archon_migrations" ON archon_migrations;
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to read archon_migrations" ON archon_migrations;
|
||||
|
||||
-- Create RLS policies for migrations table
|
||||
-- Service role has full access
|
||||
CREATE POLICY "Allow service role full access to archon_migrations" ON archon_migrations
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
-- Authenticated users can only read migrations (they cannot modify migration history)
|
||||
CREATE POLICY "Allow authenticated users to read archon_migrations" ON archon_migrations
|
||||
FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- =====================================================
|
||||
-- SECTION 8: PROMPTS TABLE
|
||||
-- =====================================================
|
||||
|
||||
170
python/src/server/api_routes/migration_api.py
Normal file
170
python/src/server/api_routes/migration_api.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
API routes for database migration tracking and management.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import logfire
|
||||
from fastapi import APIRouter, Header, HTTPException, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config.version import ARCHON_VERSION
|
||||
from ..services.migration_service import migration_service
|
||||
from ..utils.etag_utils import check_etag, generate_etag
|
||||
|
||||
|
||||
# Response models
|
||||
class MigrationRecord(BaseModel):
|
||||
"""Represents an applied migration."""
|
||||
|
||||
version: str
|
||||
migration_name: str
|
||||
applied_at: datetime
|
||||
checksum: str | None = None
|
||||
|
||||
|
||||
class PendingMigration(BaseModel):
|
||||
"""Represents a pending migration."""
|
||||
|
||||
version: str
|
||||
name: str
|
||||
sql_content: str
|
||||
file_path: str
|
||||
checksum: str | None = None
|
||||
|
||||
|
||||
class MigrationStatusResponse(BaseModel):
|
||||
"""Complete migration status response."""
|
||||
|
||||
pending_migrations: list[PendingMigration]
|
||||
applied_migrations: list[MigrationRecord]
|
||||
has_pending: bool
|
||||
bootstrap_required: bool
|
||||
current_version: str
|
||||
pending_count: int
|
||||
applied_count: int
|
||||
|
||||
|
||||
class MigrationHistoryResponse(BaseModel):
|
||||
"""Migration history response."""
|
||||
|
||||
migrations: list[MigrationRecord]
|
||||
total_count: int
|
||||
current_version: str
|
||||
|
||||
|
||||
# Create router
|
||||
router = APIRouter(prefix="/api/migrations", tags=["migrations"])
|
||||
|
||||
|
||||
@router.get("/status", response_model=MigrationStatusResponse)
|
||||
async def get_migration_status(
|
||||
response: Response, if_none_match: str | None = Header(None)
|
||||
):
|
||||
"""
|
||||
Get current migration status including pending and applied migrations.
|
||||
|
||||
Returns comprehensive migration status with:
|
||||
- List of pending migrations with SQL content
|
||||
- List of applied migrations
|
||||
- Bootstrap flag if migrations table doesn't exist
|
||||
- Current version information
|
||||
"""
|
||||
try:
|
||||
# Get migration status from service
|
||||
status = await migration_service.get_migration_status()
|
||||
|
||||
# Generate ETag for response
|
||||
etag = generate_etag(status)
|
||||
|
||||
# Check if client has current data
|
||||
if check_etag(if_none_match, etag):
|
||||
# Client has current data, return 304
|
||||
response.status_code = 304
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return Response(status_code=304)
|
||||
else:
|
||||
# Client needs new data
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return MigrationStatusResponse(**status)
|
||||
|
||||
except Exception as e:
|
||||
logfire.error(f"Error getting migration status: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get migration status: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/history", response_model=MigrationHistoryResponse)
|
||||
async def get_migration_history(response: Response, if_none_match: str | None = Header(None)):
|
||||
"""
|
||||
Get history of applied migrations.
|
||||
|
||||
Returns list of all applied migrations sorted by date.
|
||||
"""
|
||||
try:
|
||||
# Get applied migrations from service
|
||||
applied = await migration_service.get_applied_migrations()
|
||||
|
||||
# Format response
|
||||
history = {
|
||||
"migrations": [
|
||||
MigrationRecord(
|
||||
version=m.version,
|
||||
migration_name=m.migration_name,
|
||||
applied_at=m.applied_at,
|
||||
checksum=m.checksum,
|
||||
)
|
||||
for m in applied
|
||||
],
|
||||
"total_count": len(applied),
|
||||
"current_version": ARCHON_VERSION,
|
||||
}
|
||||
|
||||
# Generate ETag for response
|
||||
etag = generate_etag(history)
|
||||
|
||||
# Check if client has current data
|
||||
if check_etag(if_none_match, etag):
|
||||
# Client has current data, return 304
|
||||
response.status_code = 304
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return Response(status_code=304)
|
||||
else:
|
||||
# Client needs new data
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return MigrationHistoryResponse(**history)
|
||||
|
||||
except Exception as e:
|
||||
logfire.error(f"Error getting migration history: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get migration history: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/pending", response_model=list[PendingMigration])
|
||||
async def get_pending_migrations():
|
||||
"""
|
||||
Get list of pending migrations only.
|
||||
|
||||
Returns simplified list of migrations that need to be applied.
|
||||
"""
|
||||
try:
|
||||
# Get pending migrations from service
|
||||
pending = await migration_service.get_pending_migrations()
|
||||
|
||||
# Format response
|
||||
return [
|
||||
PendingMigration(
|
||||
version=m.version,
|
||||
name=m.name,
|
||||
sql_content=m.sql_content,
|
||||
file_path=m.file_path,
|
||||
checksum=m.checksum,
|
||||
)
|
||||
for m in pending
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logfire.error(f"Error getting pending migrations: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get pending migrations: {str(e)}") from e
|
||||
121
python/src/server/api_routes/version_api.py
Normal file
121
python/src/server/api_routes/version_api.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
API routes for version checking and update management.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import logfire
|
||||
from fastapi import APIRouter, Header, HTTPException, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config.version import ARCHON_VERSION
|
||||
from ..services.version_service import version_service
|
||||
from ..utils.etag_utils import check_etag, generate_etag
|
||||
|
||||
|
||||
# Response models
|
||||
class ReleaseAsset(BaseModel):
|
||||
"""Represents a downloadable asset from a release."""
|
||||
|
||||
name: str
|
||||
size: int
|
||||
download_count: int
|
||||
browser_download_url: str
|
||||
content_type: str
|
||||
|
||||
|
||||
class VersionCheckResponse(BaseModel):
|
||||
"""Version check response with update information."""
|
||||
|
||||
current: str
|
||||
latest: str | None
|
||||
update_available: bool
|
||||
release_url: str | None
|
||||
release_notes: str | None
|
||||
published_at: datetime | None
|
||||
check_error: str | None = None
|
||||
assets: list[dict[str, Any]] | None = None
|
||||
author: str | None = None
|
||||
|
||||
|
||||
class CurrentVersionResponse(BaseModel):
|
||||
"""Simple current version response."""
|
||||
|
||||
version: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
# Create router
|
||||
router = APIRouter(prefix="/api/version", tags=["version"])
|
||||
|
||||
|
||||
@router.get("/check", response_model=VersionCheckResponse)
|
||||
async def check_for_updates(response: Response, if_none_match: str | None = Header(None)):
|
||||
"""
|
||||
Check for available Archon updates.
|
||||
|
||||
Queries GitHub releases API to determine if a newer version is available.
|
||||
Results are cached for 1 hour to avoid rate limiting.
|
||||
|
||||
Returns:
|
||||
Version information including current, latest, and update availability
|
||||
"""
|
||||
try:
|
||||
# Get version check results from service
|
||||
result = await version_service.check_for_updates()
|
||||
|
||||
# Generate ETag for response
|
||||
etag = generate_etag(result)
|
||||
|
||||
# Check if client has current data
|
||||
if check_etag(if_none_match, etag):
|
||||
# Client has current data, return 304
|
||||
response.status_code = 304
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return Response(status_code=304)
|
||||
else:
|
||||
# Client needs new data
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return VersionCheckResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logfire.error(f"Error checking for updates: {e}")
|
||||
# Return safe response with error
|
||||
return VersionCheckResponse(
|
||||
current=ARCHON_VERSION,
|
||||
latest=None,
|
||||
update_available=False,
|
||||
release_url=None,
|
||||
release_notes=None,
|
||||
published_at=None,
|
||||
check_error=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentVersionResponse)
|
||||
async def get_current_version():
|
||||
"""
|
||||
Get the current Archon version.
|
||||
|
||||
Simple endpoint that returns the installed version without checking for updates.
|
||||
"""
|
||||
return CurrentVersionResponse(version=ARCHON_VERSION, timestamp=datetime.now())
|
||||
|
||||
|
||||
@router.post("/clear-cache")
|
||||
async def clear_version_cache():
|
||||
"""
|
||||
Clear the version check cache.
|
||||
|
||||
Forces the next version check to query GitHub API instead of using cached data.
|
||||
Useful for testing or forcing an immediate update check.
|
||||
"""
|
||||
try:
|
||||
version_service.clear_cache()
|
||||
return {"message": "Version cache cleared successfully", "success": True}
|
||||
except Exception as e:
|
||||
logfire.error(f"Error clearing version cache: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}") from e
|
||||
11
python/src/server/config/version.py
Normal file
11
python/src/server/config/version.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Version configuration for Archon.
|
||||
"""
|
||||
|
||||
# Current version of Archon
|
||||
# Update this with each release
|
||||
ARCHON_VERSION = "0.1.0"
|
||||
|
||||
# Repository information for GitHub API
|
||||
GITHUB_REPO_OWNER = "coleam00"
|
||||
GITHUB_REPO_NAME = "Archon"
|
||||
@@ -23,9 +23,11 @@ from .api_routes.bug_report_api import router as bug_report_router
|
||||
from .api_routes.internal_api import router as internal_router
|
||||
from .api_routes.knowledge_api import router as knowledge_router
|
||||
from .api_routes.mcp_api import router as mcp_router
|
||||
from .api_routes.migration_api import router as migration_router
|
||||
from .api_routes.ollama_api import router as ollama_router
|
||||
from .api_routes.progress_api import router as progress_router
|
||||
from .api_routes.projects_api import router as projects_router
|
||||
from .api_routes.version_api import router as version_router
|
||||
|
||||
# Import modular API routers
|
||||
from .api_routes.settings_api import router as settings_router
|
||||
@@ -186,6 +188,8 @@ app.include_router(progress_router)
|
||||
app.include_router(agent_chat_router)
|
||||
app.include_router(internal_router)
|
||||
app.include_router(bug_report_router)
|
||||
app.include_router(version_router)
|
||||
app.include_router(migration_router)
|
||||
|
||||
|
||||
# Root endpoint
|
||||
|
||||
233
python/src/server/services/migration_service.py
Normal file
233
python/src/server/services/migration_service.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Database migration tracking and management service.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import logfire
|
||||
from supabase import Client
|
||||
|
||||
from .client_manager import get_supabase_client
|
||||
from ..config.version import ARCHON_VERSION
|
||||
|
||||
|
||||
class MigrationRecord:
|
||||
"""Represents a migration record from the database."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self.id = data.get("id")
|
||||
self.version = data.get("version")
|
||||
self.migration_name = data.get("migration_name")
|
||||
self.applied_at = data.get("applied_at")
|
||||
self.checksum = data.get("checksum")
|
||||
|
||||
|
||||
class PendingMigration:
|
||||
"""Represents a pending migration from the filesystem."""
|
||||
|
||||
def __init__(self, version: str, name: str, sql_content: str, file_path: str):
|
||||
self.version = version
|
||||
self.name = name
|
||||
self.sql_content = sql_content
|
||||
self.file_path = file_path
|
||||
self.checksum = self._calculate_checksum(sql_content)
|
||||
|
||||
def _calculate_checksum(self, content: str) -> str:
|
||||
"""Calculate MD5 checksum of migration content."""
|
||||
return hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
|
||||
class MigrationService:
|
||||
"""Service for managing database migrations."""
|
||||
|
||||
def __init__(self):
|
||||
self._supabase: Client | None = None
|
||||
# Handle both Docker (/app/migration) and local (./migration) environments
|
||||
if Path("/app/migration").exists():
|
||||
self._migrations_dir = Path("/app/migration")
|
||||
else:
|
||||
self._migrations_dir = Path("migration")
|
||||
|
||||
def _get_supabase_client(self) -> Client:
|
||||
"""Get or create Supabase client."""
|
||||
if not self._supabase:
|
||||
self._supabase = get_supabase_client()
|
||||
return self._supabase
|
||||
|
||||
async def check_migrations_table_exists(self) -> bool:
|
||||
"""
|
||||
Check if the archon_migrations table exists in the database.
|
||||
|
||||
Returns:
|
||||
True if table exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
supabase = self._get_supabase_client()
|
||||
|
||||
# Query to check if table exists
|
||||
result = supabase.rpc(
|
||||
"sql",
|
||||
{
|
||||
"query": """
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'archon_migrations'
|
||||
) as exists
|
||||
"""
|
||||
}
|
||||
).execute()
|
||||
|
||||
# Check if result indicates table exists
|
||||
if result.data and len(result.data) > 0:
|
||||
return result.data[0].get("exists", False)
|
||||
return False
|
||||
except Exception:
|
||||
# If the SQL function doesn't exist or query fails, try direct query
|
||||
try:
|
||||
supabase = self._get_supabase_client()
|
||||
# Try to select from the table with limit 0
|
||||
supabase.table("archon_migrations").select("id").limit(0).execute()
|
||||
return True
|
||||
except Exception as e:
|
||||
logfire.info(f"Migrations table does not exist: {e}")
|
||||
return False
|
||||
|
||||
async def get_applied_migrations(self) -> list[MigrationRecord]:
|
||||
"""
|
||||
Get list of applied migrations from the database.
|
||||
|
||||
Returns:
|
||||
List of MigrationRecord objects
|
||||
"""
|
||||
try:
|
||||
# Check if table exists first
|
||||
if not await self.check_migrations_table_exists():
|
||||
logfire.info("Migrations table does not exist, returning empty list")
|
||||
return []
|
||||
|
||||
supabase = self._get_supabase_client()
|
||||
result = supabase.table("archon_migrations").select("*").order("applied_at", desc=True).execute()
|
||||
|
||||
return [MigrationRecord(row) for row in result.data]
|
||||
except Exception as e:
|
||||
logfire.error(f"Error fetching applied migrations: {e}")
|
||||
# Return empty list if we can't fetch migrations
|
||||
return []
|
||||
|
||||
async def scan_migration_directory(self) -> list[PendingMigration]:
|
||||
"""
|
||||
Scan the migration directory for all SQL files.
|
||||
|
||||
Returns:
|
||||
List of PendingMigration objects
|
||||
"""
|
||||
migrations = []
|
||||
|
||||
if not self._migrations_dir.exists():
|
||||
logfire.warning(f"Migration directory does not exist: {self._migrations_dir}")
|
||||
return migrations
|
||||
|
||||
# Scan all version directories
|
||||
for version_dir in sorted(self._migrations_dir.iterdir()):
|
||||
if not version_dir.is_dir():
|
||||
continue
|
||||
|
||||
version = version_dir.name
|
||||
|
||||
# Scan all SQL files in version directory
|
||||
for sql_file in sorted(version_dir.glob("*.sql")):
|
||||
try:
|
||||
# Read SQL content
|
||||
with open(sql_file, encoding="utf-8") as f:
|
||||
sql_content = f.read()
|
||||
|
||||
# Extract migration name (filename without extension)
|
||||
migration_name = sql_file.stem
|
||||
|
||||
# Create pending migration object
|
||||
migration = PendingMigration(
|
||||
version=version,
|
||||
name=migration_name,
|
||||
sql_content=sql_content,
|
||||
file_path=str(sql_file.relative_to(Path.cwd())),
|
||||
)
|
||||
migrations.append(migration)
|
||||
except Exception as e:
|
||||
logfire.error(f"Error reading migration file {sql_file}: {e}")
|
||||
|
||||
return migrations
|
||||
|
||||
async def get_pending_migrations(self) -> list[PendingMigration]:
|
||||
"""
|
||||
Get list of pending migrations by comparing filesystem with database.
|
||||
|
||||
Returns:
|
||||
List of PendingMigration objects that haven't been applied
|
||||
"""
|
||||
# Get all migrations from filesystem
|
||||
all_migrations = await self.scan_migration_directory()
|
||||
|
||||
# Check if migrations table exists
|
||||
if not await self.check_migrations_table_exists():
|
||||
# Bootstrap case - all migrations are pending
|
||||
logfire.info("Migrations table doesn't exist, all migrations are pending")
|
||||
return all_migrations
|
||||
|
||||
# Get applied migrations from database
|
||||
applied_migrations = await self.get_applied_migrations()
|
||||
|
||||
# Create set of applied migration identifiers
|
||||
applied_set = {(m.version, m.migration_name) for m in applied_migrations}
|
||||
|
||||
# Filter out applied migrations
|
||||
pending = [m for m in all_migrations if (m.version, m.name) not in applied_set]
|
||||
|
||||
return pending
|
||||
|
||||
async def get_migration_status(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive migration status.
|
||||
|
||||
Returns:
|
||||
Dictionary with pending and applied migrations info
|
||||
"""
|
||||
pending = await self.get_pending_migrations()
|
||||
applied = await self.get_applied_migrations()
|
||||
|
||||
# Check if bootstrap is required
|
||||
bootstrap_required = not await self.check_migrations_table_exists()
|
||||
|
||||
return {
|
||||
"pending_migrations": [
|
||||
{
|
||||
"version": m.version,
|
||||
"name": m.name,
|
||||
"sql_content": m.sql_content,
|
||||
"file_path": m.file_path,
|
||||
"checksum": m.checksum,
|
||||
}
|
||||
for m in pending
|
||||
],
|
||||
"applied_migrations": [
|
||||
{
|
||||
"version": m.version,
|
||||
"migration_name": m.migration_name,
|
||||
"applied_at": m.applied_at,
|
||||
"checksum": m.checksum,
|
||||
}
|
||||
for m in applied
|
||||
],
|
||||
"has_pending": len(pending) > 0,
|
||||
"bootstrap_required": bootstrap_required,
|
||||
"current_version": ARCHON_VERSION,
|
||||
"pending_count": len(pending),
|
||||
"applied_count": len(applied),
|
||||
}
|
||||
|
||||
|
||||
# Export singleton instance
|
||||
migration_service = MigrationService()
|
||||
162
python/src/server/services/version_service.py
Normal file
162
python/src/server/services/version_service.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Version checking service with GitHub API integration.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import logfire
|
||||
|
||||
from ..config.version import ARCHON_VERSION, GITHUB_REPO_NAME, GITHUB_REPO_OWNER
|
||||
from ..utils.semantic_version import is_newer_version
|
||||
|
||||
|
||||
class VersionService:
|
||||
"""Service for checking Archon version against GitHub releases."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: dict[str, Any] | None = None
|
||||
self._cache_time: datetime | None = None
|
||||
self._cache_ttl = 3600 # 1 hour cache TTL
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Check if cached data is still valid."""
|
||||
if not self._cache or not self._cache_time:
|
||||
return False
|
||||
|
||||
age = datetime.now() - self._cache_time
|
||||
return age < timedelta(seconds=self._cache_ttl)
|
||||
|
||||
async def get_latest_release(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Fetch latest release information from GitHub API.
|
||||
|
||||
Returns:
|
||||
Release data dictionary or None if no releases
|
||||
"""
|
||||
# Check cache first
|
||||
if self._is_cache_valid():
|
||||
logfire.debug("Using cached version data")
|
||||
return self._cache
|
||||
|
||||
# GitHub API endpoint
|
||||
url = f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/releases/latest"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": f"Archon/{ARCHON_VERSION}",
|
||||
},
|
||||
)
|
||||
|
||||
# Handle 404 - no releases yet
|
||||
if response.status_code == 404:
|
||||
logfire.info("No releases found on GitHub")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Cache the successful response
|
||||
self._cache = data
|
||||
self._cache_time = datetime.now()
|
||||
|
||||
return data
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logfire.warning("GitHub API request timed out")
|
||||
# Return cached data if available
|
||||
if self._cache:
|
||||
return self._cache
|
||||
return None
|
||||
except httpx.HTTPError as e:
|
||||
logfire.error(f"HTTP error fetching latest release: {e}")
|
||||
# Return cached data if available
|
||||
if self._cache:
|
||||
return self._cache
|
||||
return None
|
||||
except Exception as e:
|
||||
logfire.error(f"Unexpected error fetching latest release: {e}")
|
||||
# Return cached data if available
|
||||
if self._cache:
|
||||
return self._cache
|
||||
return None
|
||||
|
||||
async def check_for_updates(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if a newer version of Archon is available.
|
||||
|
||||
Returns:
|
||||
Dictionary with version check results
|
||||
"""
|
||||
try:
|
||||
# Get latest release from GitHub
|
||||
release = await self.get_latest_release()
|
||||
|
||||
if not release:
|
||||
# No releases found or error occurred
|
||||
return {
|
||||
"current": ARCHON_VERSION,
|
||||
"latest": None,
|
||||
"update_available": False,
|
||||
"release_url": None,
|
||||
"release_notes": None,
|
||||
"published_at": None,
|
||||
"check_error": None,
|
||||
}
|
||||
|
||||
# Extract version from tag_name (e.g., "v1.0.0" -> "1.0.0")
|
||||
latest_version = release.get("tag_name", "")
|
||||
if latest_version.startswith("v"):
|
||||
latest_version = latest_version[1:]
|
||||
|
||||
# Check if update is available
|
||||
update_available = is_newer_version(ARCHON_VERSION, latest_version)
|
||||
|
||||
# Parse published date
|
||||
published_at = None
|
||||
if release.get("published_at"):
|
||||
try:
|
||||
published_at = datetime.fromisoformat(
|
||||
release["published_at"].replace("Z", "+00:00")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"current": ARCHON_VERSION,
|
||||
"latest": latest_version,
|
||||
"update_available": update_available,
|
||||
"release_url": release.get("html_url"),
|
||||
"release_notes": release.get("body"),
|
||||
"published_at": published_at,
|
||||
"check_error": None,
|
||||
"assets": release.get("assets", []),
|
||||
"author": release.get("author", {}).get("login"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logfire.error(f"Error checking for updates: {e}")
|
||||
# Return safe default with error
|
||||
return {
|
||||
"current": ARCHON_VERSION,
|
||||
"latest": None,
|
||||
"update_available": False,
|
||||
"release_url": None,
|
||||
"release_notes": None,
|
||||
"published_at": None,
|
||||
"check_error": str(e),
|
||||
}
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the cached version data."""
|
||||
self._cache = None
|
||||
self._cache_time = None
|
||||
|
||||
|
||||
# Export singleton instance
|
||||
version_service = VersionService()
|
||||
107
python/src/server/utils/semantic_version.py
Normal file
107
python/src/server/utils/semantic_version.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Semantic version parsing and comparison utilities.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def parse_version(version_string: str) -> tuple[int, int, int, str | None]:
|
||||
"""
|
||||
Parse a semantic version string into major, minor, patch, and optional prerelease.
|
||||
|
||||
Supports formats like:
|
||||
- "1.0.0"
|
||||
- "v1.0.0"
|
||||
- "1.0.0-beta"
|
||||
- "v1.0.0-rc.1"
|
||||
|
||||
Args:
|
||||
version_string: Version string to parse
|
||||
|
||||
Returns:
|
||||
Tuple of (major, minor, patch, prerelease)
|
||||
"""
|
||||
# Remove 'v' prefix if present
|
||||
version = version_string.strip()
|
||||
if version.lower().startswith('v'):
|
||||
version = version[1:]
|
||||
|
||||
# Parse version with optional prerelease
|
||||
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$'
|
||||
match = re.match(pattern, version)
|
||||
|
||||
if not match:
|
||||
# Try to handle incomplete versions like "1.0"
|
||||
simple_pattern = r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?$'
|
||||
simple_match = re.match(simple_pattern, version)
|
||||
if simple_match:
|
||||
major = int(simple_match.group(1))
|
||||
minor = int(simple_match.group(2) or 0)
|
||||
patch = int(simple_match.group(3) or 0)
|
||||
return (major, minor, patch, None)
|
||||
raise ValueError(f"Invalid version string: {version_string}")
|
||||
|
||||
major = int(match.group(1))
|
||||
minor = int(match.group(2))
|
||||
patch = int(match.group(3))
|
||||
prerelease = match.group(4)
|
||||
|
||||
return (major, minor, patch, prerelease)
|
||||
|
||||
|
||||
def compare_versions(version1: str, version2: str) -> int:
|
||||
"""
|
||||
Compare two semantic version strings.
|
||||
|
||||
Args:
|
||||
version1: First version string
|
||||
version2: Second version string
|
||||
|
||||
Returns:
|
||||
-1 if version1 < version2
|
||||
0 if version1 == version2
|
||||
1 if version1 > version2
|
||||
"""
|
||||
v1 = parse_version(version1)
|
||||
v2 = parse_version(version2)
|
||||
|
||||
# Compare major, minor, patch
|
||||
for i in range(3):
|
||||
if v1[i] < v2[i]:
|
||||
return -1
|
||||
elif v1[i] > v2[i]:
|
||||
return 1
|
||||
|
||||
# If main versions are equal, check prerelease
|
||||
# No prerelease is considered newer than any prerelease
|
||||
if v1[3] is None and v2[3] is None:
|
||||
return 0
|
||||
elif v1[3] is None:
|
||||
return 1 # v1 is release, v2 is prerelease
|
||||
elif v2[3] is None:
|
||||
return -1 # v1 is prerelease, v2 is release
|
||||
else:
|
||||
# Both have prereleases, compare lexicographically
|
||||
if v1[3] < v2[3]:
|
||||
return -1
|
||||
elif v1[3] > v2[3]:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def is_newer_version(current: str, latest: str) -> bool:
|
||||
"""
|
||||
Check if latest version is newer than current version.
|
||||
|
||||
Args:
|
||||
current: Current version string
|
||||
latest: Latest version string to compare
|
||||
|
||||
Returns:
|
||||
True if latest > current, False otherwise
|
||||
"""
|
||||
try:
|
||||
return compare_versions(latest, current) > 0
|
||||
except ValueError:
|
||||
# If we can't parse versions, assume no update
|
||||
return False
|
||||
Reference in New Issue
Block a user