Migrations and version APIs initial

This commit is contained in:
Cole Medin
2025-09-20 12:25:54 -05:00
parent 6ef4312fec
commit 1dff0cb953
24 changed files with 2706 additions and 2 deletions

View 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

View 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

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
});
}

View File

@@ -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;
}
},
};

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 });
},
});
}

View File

@@ -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;
}
},
};

View 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;
}

View File

@@ -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

View File

@@ -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:

View 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);

View File

@@ -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;

View File

@@ -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
-- =====================================================

View 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

View 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

View 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"

View File

@@ -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

View 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()

View 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()

View 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