Migrations and version APIs (#718)

* Preparing migration folder for the migration alert implementation

* Migrations and version APIs initial

* Touching up update instructions in README and UI

* Unit tests for migrations and version APIs

* Splitting up the Ollama migration scripts

* Removing temporary PRPs

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
This commit is contained in:
Cole Medin
2025-09-22 04:25:58 -05:00
committed by GitHub
parent 7a4c67cf90
commit 3ff3f7f2dc
38 changed files with 3124 additions and 978 deletions

View File

@@ -206,14 +206,18 @@ To upgrade Archon to the latest version:
git pull git pull
``` ```
2. **Check for migrations**: Look in the `migration/` folder for any SQL files newer than your last update. Check the file created dates to determine if you need to run them. You can run these in the SQL editor just like you did when you first set up Archon. We are also working on a way to make handling these migrations automatic! 2. **Rebuild and restart containers**:
3. **Rebuild and restart**:
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
This rebuilds containers with the latest code and restarts all services.
This is the same command used for initial setup - it rebuilds containers with the latest code and restarts services. 3. **Check for database migrations**:
- Open the Archon settings in your browser: [http://localhost:3737/settings](http://localhost:3737/settings)
- Navigate to the **Database Migrations** section
- If there are pending migrations, the UI will display them with clear instructions
- Click on each migration to view and copy the SQL
- Run the SQL scripts in your Supabase SQL editor in the order shown
## What's Included ## What's Included

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,69 @@
/**
* 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>
)}
<a
href="https://github.com/coleam00/Archon#upgrading"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-500/20 hover:bg-purple-500/30 border border-purple-500/50 rounded-lg text-purple-400 transition-all duration-200"
>
<span className="text-sm font-medium">View Upgrade Instructions</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, Code,
FileCode, FileCode,
Bug, Bug,
Info,
Database,
} from "lucide-react"; } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useToast } from "../features/ui/hooks/useToast"; import { useToast } from "../features/ui/hooks/useToast";
@@ -28,6 +30,9 @@ import {
RagSettings, RagSettings,
CodeExtractionSettings as CodeExtractionSettingsType, CodeExtractionSettings as CodeExtractionSettingsType,
} from "../services/credentialsService"; } 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 = () => { export const SettingsPage = () => {
const [ragSettings, setRagSettings] = useState<RagSettings>({ const [ragSettings, setRagSettings] = useState<RagSettings>({
@@ -106,6 +111,9 @@ export const SettingsPage = () => {
variants={containerVariants} variants={containerVariants}
className="w-full" className="w-full"
> >
{/* Update Banner */}
<UpdateBanner />
{/* Header */} {/* Header */}
<motion.div <motion.div
className="flex justify-between items-center mb-8" className="flex justify-between items-center mb-8"
@@ -136,6 +144,33 @@ export const SettingsPage = () => {
<FeaturesSection /> <FeaturesSection />
</CollapsibleSettingsCard> </CollapsibleSettingsCard>
</motion.div> </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 && ( {projectsEnabled && (
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
<CollapsibleSettingsCard <CollapsibleSettingsCard

View File

@@ -35,6 +35,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for MCP container control - /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/src:/app/src # Mount source code for hot reload
- ./python/tests:/app/tests # Mount tests for UI test execution - ./python/tests:/app/tests # Mount tests for UI test execution
- ./migration:/app/migration # Mount migration files for version tracking
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
command: command:

View File

@@ -0,0 +1,35 @@
-- ======================================================================
-- Migration 003: Ollama Implementation - Add Columns
-- Adds multi-dimensional embedding support columns
-- ======================================================================
-- Increase memory for this session
SET maintenance_work_mem = '256MB';
BEGIN;
-- Add multi-dimensional embedding columns to archon_crawled_pages
ALTER TABLE archon_crawled_pages
ADD COLUMN IF NOT EXISTS embedding_384 VECTOR(384),
ADD COLUMN IF NOT EXISTS embedding_768 VECTOR(768),
ADD COLUMN IF NOT EXISTS embedding_1024 VECTOR(1024),
ADD COLUMN IF NOT EXISTS embedding_1536 VECTOR(1536),
ADD COLUMN IF NOT EXISTS embedding_3072 VECTOR(3072),
ADD COLUMN IF NOT EXISTS llm_chat_model TEXT,
ADD COLUMN IF NOT EXISTS embedding_model TEXT,
ADD COLUMN IF NOT EXISTS embedding_dimension INTEGER;
-- Add multi-dimensional embedding columns to archon_code_examples
ALTER TABLE archon_code_examples
ADD COLUMN IF NOT EXISTS embedding_384 VECTOR(384),
ADD COLUMN IF NOT EXISTS embedding_768 VECTOR(768),
ADD COLUMN IF NOT EXISTS embedding_1024 VECTOR(1024),
ADD COLUMN IF NOT EXISTS embedding_1536 VECTOR(1536),
ADD COLUMN IF NOT EXISTS embedding_3072 VECTOR(3072),
ADD COLUMN IF NOT EXISTS llm_chat_model TEXT,
ADD COLUMN IF NOT EXISTS embedding_model TEXT,
ADD COLUMN IF NOT EXISTS embedding_dimension INTEGER;
COMMIT;
SELECT 'Ollama columns added successfully' AS status;

View File

@@ -0,0 +1,70 @@
-- ======================================================================
-- Migration 004: Ollama Implementation - Migrate Data
-- Migrates existing embeddings to new multi-dimensional columns
-- ======================================================================
BEGIN;
-- Migrate existing embedding data from old column (if exists)
DO $$
DECLARE
crawled_pages_count INTEGER;
code_examples_count INTEGER;
dimension_detected INTEGER;
BEGIN
-- Check if old embedding column exists
SELECT COUNT(*) INTO crawled_pages_count
FROM information_schema.columns
WHERE table_name = 'archon_crawled_pages'
AND column_name = 'embedding';
IF crawled_pages_count > 0 THEN
-- Detect dimension
SELECT vector_dims(embedding) INTO dimension_detected
FROM archon_crawled_pages
WHERE embedding IS NOT NULL
LIMIT 1;
IF dimension_detected = 1536 THEN
UPDATE archon_crawled_pages
SET embedding_1536 = embedding,
embedding_dimension = 1536,
embedding_model = COALESCE(embedding_model, 'text-embedding-3-small')
WHERE embedding IS NOT NULL AND embedding_1536 IS NULL;
END IF;
-- Drop old column
ALTER TABLE archon_crawled_pages DROP COLUMN IF EXISTS embedding;
END IF;
-- Same for code_examples
SELECT COUNT(*) INTO code_examples_count
FROM information_schema.columns
WHERE table_name = 'archon_code_examples'
AND column_name = 'embedding';
IF code_examples_count > 0 THEN
SELECT vector_dims(embedding) INTO dimension_detected
FROM archon_code_examples
WHERE embedding IS NOT NULL
LIMIT 1;
IF dimension_detected = 1536 THEN
UPDATE archon_code_examples
SET embedding_1536 = embedding,
embedding_dimension = 1536,
embedding_model = COALESCE(embedding_model, 'text-embedding-3-small')
WHERE embedding IS NOT NULL AND embedding_1536 IS NULL;
END IF;
ALTER TABLE archon_code_examples DROP COLUMN IF EXISTS embedding;
END IF;
END $$;
-- Drop old indexes if they exist
DROP INDEX IF EXISTS idx_archon_crawled_pages_embedding;
DROP INDEX IF EXISTS idx_archon_code_examples_embedding;
COMMIT;
SELECT 'Ollama data migrated successfully' AS status;

View File

@@ -0,0 +1,172 @@
-- ======================================================================
-- Migration 005: Ollama Implementation - Create Functions
-- Creates search functions for multi-dimensional embeddings
-- ======================================================================
BEGIN;
-- Helper function to detect embedding dimension
CREATE OR REPLACE FUNCTION detect_embedding_dimension(embedding_vector vector)
RETURNS INTEGER AS $$
BEGIN
RETURN vector_dims(embedding_vector);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Helper function to get column name for dimension
CREATE OR REPLACE FUNCTION get_embedding_column_name(dimension INTEGER)
RETURNS TEXT AS $$
BEGIN
CASE dimension
WHEN 384 THEN RETURN 'embedding_384';
WHEN 768 THEN RETURN 'embedding_768';
WHEN 1024 THEN RETURN 'embedding_1024';
WHEN 1536 THEN RETURN 'embedding_1536';
WHEN 3072 THEN RETURN 'embedding_3072';
ELSE RAISE EXCEPTION 'Unsupported embedding dimension: %', dimension;
END CASE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Multi-dimensional search for crawled pages
CREATE OR REPLACE FUNCTION match_archon_crawled_pages_multi (
query_embedding VECTOR,
embedding_dimension INTEGER,
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
#variable_conflict use_column
DECLARE
sql_query TEXT;
embedding_column TEXT;
BEGIN
CASE embedding_dimension
WHEN 384 THEN embedding_column := 'embedding_384';
WHEN 768 THEN embedding_column := 'embedding_768';
WHEN 1024 THEN embedding_column := 'embedding_1024';
WHEN 1536 THEN embedding_column := 'embedding_1536';
WHEN 3072 THEN embedding_column := 'embedding_3072';
ELSE RAISE EXCEPTION 'Unsupported embedding dimension: %', embedding_dimension;
END CASE;
sql_query := format('
SELECT id, url, chunk_number, content, metadata, source_id,
1 - (%I <=> $1) AS similarity
FROM archon_crawled_pages
WHERE (%I IS NOT NULL)
AND metadata @> $3
AND ($4 IS NULL OR source_id = $4)
ORDER BY %I <=> $1
LIMIT $2',
embedding_column, embedding_column, embedding_column);
RETURN QUERY EXECUTE sql_query USING query_embedding, match_count, filter, source_filter;
END;
$$;
-- Multi-dimensional search for code examples
CREATE OR REPLACE FUNCTION match_archon_code_examples_multi (
query_embedding VECTOR,
embedding_dimension INTEGER,
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
summary TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
#variable_conflict use_column
DECLARE
sql_query TEXT;
embedding_column TEXT;
BEGIN
CASE embedding_dimension
WHEN 384 THEN embedding_column := 'embedding_384';
WHEN 768 THEN embedding_column := 'embedding_768';
WHEN 1024 THEN embedding_column := 'embedding_1024';
WHEN 1536 THEN embedding_column := 'embedding_1536';
WHEN 3072 THEN embedding_column := 'embedding_3072';
ELSE RAISE EXCEPTION 'Unsupported embedding dimension: %', embedding_dimension;
END CASE;
sql_query := format('
SELECT id, url, chunk_number, content, summary, metadata, source_id,
1 - (%I <=> $1) AS similarity
FROM archon_code_examples
WHERE (%I IS NOT NULL)
AND metadata @> $3
AND ($4 IS NULL OR source_id = $4)
ORDER BY %I <=> $1
LIMIT $2',
embedding_column, embedding_column, embedding_column);
RETURN QUERY EXECUTE sql_query USING query_embedding, match_count, filter, source_filter;
END;
$$;
-- Legacy compatibility (defaults to 1536D)
CREATE OR REPLACE FUNCTION match_archon_crawled_pages (
query_embedding VECTOR(1536),
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT * FROM match_archon_crawled_pages_multi(query_embedding, 1536, match_count, filter, source_filter);
END;
$$;
CREATE OR REPLACE FUNCTION match_archon_code_examples (
query_embedding VECTOR(1536),
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
summary TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT * FROM match_archon_code_examples_multi(query_embedding, 1536, match_count, filter, source_filter);
END;
$$;
COMMIT;
SELECT 'Ollama functions created successfully' AS status;

View File

@@ -0,0 +1,67 @@
-- ======================================================================
-- Migration 006: Ollama Implementation - Create Indexes (Optional)
-- Creates vector indexes for performance (may timeout on large datasets)
-- ======================================================================
-- IMPORTANT: This migration creates vector indexes which are memory-intensive
-- If this fails, you can skip it and the system will use brute-force search
-- You can create these indexes later via direct database connection
SET maintenance_work_mem = '512MB';
SET statement_timeout = '10min';
-- Create ONE index at a time to avoid memory issues
-- Comment out any that fail and continue with the next
-- Index 1 of 8
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_1536
ON archon_crawled_pages USING ivfflat (embedding_1536 vector_cosine_ops)
WITH (lists = 100);
-- Index 2 of 8
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_1536
ON archon_code_examples USING ivfflat (embedding_1536 vector_cosine_ops)
WITH (lists = 100);
-- Index 3 of 8
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_768
ON archon_crawled_pages USING ivfflat (embedding_768 vector_cosine_ops)
WITH (lists = 100);
-- Index 4 of 8
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_768
ON archon_code_examples USING ivfflat (embedding_768 vector_cosine_ops)
WITH (lists = 100);
-- Index 5 of 8
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_384
ON archon_crawled_pages USING ivfflat (embedding_384 vector_cosine_ops)
WITH (lists = 100);
-- Index 6 of 8
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_384
ON archon_code_examples USING ivfflat (embedding_384 vector_cosine_ops)
WITH (lists = 100);
-- Index 7 of 8
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_1024
ON archon_crawled_pages USING ivfflat (embedding_1024 vector_cosine_ops)
WITH (lists = 100);
-- Index 8 of 8
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_1024
ON archon_code_examples USING ivfflat (embedding_1024 vector_cosine_ops)
WITH (lists = 100);
-- Simple B-tree indexes (these are fast)
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_model ON archon_crawled_pages (embedding_model);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_dimension ON archon_crawled_pages (embedding_dimension);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_llm_chat_model ON archon_crawled_pages (llm_chat_model);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_model ON archon_code_examples (embedding_model);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_dimension ON archon_code_examples (embedding_dimension);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_llm_chat_model ON archon_code_examples (llm_chat_model);
RESET maintenance_work_mem;
RESET statement_timeout;
SELECT 'Ollama indexes created (or skipped if timed out - that issue will be obvious in Supabase)' AS status;

View File

@@ -0,0 +1,65 @@
-- Migration: 008_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', '008_add_migration_tracking')
ON CONFLICT (version, migration_name) DO NOTHING;
-- Retroactively record previously applied migrations (001-007)
-- 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_add_columns'),
('0.1.0', '004_ollama_migrate_data'),
('0.1.0', '005_ollama_create_functions'),
('0.1.0', '006_ollama_create_indexes_optional'),
('0.1.0', '007_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

@@ -0,0 +1,157 @@
# Archon Database Migrations
This folder contains database migration scripts for upgrading existing Archon installations.
## Available Migration Scripts
### 1. `backup_database.sql` - Pre-Migration Backup
**Always run this FIRST before any migration!**
Creates timestamped backup tables of all your existing data:
- ✅ Complete backup of `archon_crawled_pages`
- ✅ Complete backup of `archon_code_examples`
- ✅ Complete backup of `archon_sources`
- ✅ Easy restore commands provided
- ✅ Row count verification
### 2. Migration Scripts (Run in Order)
You only have to run the ones you haven't already! If you don't remember exactly, it is okay to rerun migration scripts.
**2.1. `001_add_source_url_display_name.sql`**
- Adds display name field to sources table
- Improves UI presentation of crawled sources
**2.2. `002_add_hybrid_search_tsvector.sql`**
- Adds full-text search capabilities
- Implements hybrid search with tsvector columns
- Creates optimized search indexes
**2.3. `003_ollama_add_columns.sql`**
- Adds multi-dimensional embedding columns (384, 768, 1024, 1536, 3072 dimensions)
- Adds model tracking fields (`llm_chat_model`, `embedding_model`, `embedding_dimension`)
**2.4. `004_ollama_migrate_data.sql`**
- Migrates existing embeddings to new multi-dimensional columns
- Drops old embedding column after migration
- Removes obsolete indexes
**2.5. `005_ollama_create_functions.sql`**
- Creates search functions for multi-dimensional embeddings
- Adds helper functions for dimension detection
- Maintains backward compatibility with legacy search functions
**2.6. `006_ollama_create_indexes_optional.sql`**
- Creates vector indexes for performance (may timeout on large datasets)
- Creates B-tree indexes for model fields
- Can be skipped if timeout occurs (system will use brute-force search)
**2.7. `007_add_priority_column_to_tasks.sql`**
- Adds priority field to tasks table
- Enables task prioritization in project management
**2.8. `008_add_migration_tracking.sql`**
- Creates migration tracking table
- Records all applied migrations
- Enables migration version control
## Migration Process (Follow This Order!)
### Step 1: Backup Your Data
```sql
-- Run: backup_database.sql
-- This creates timestamped backup tables of all your data
```
### Step 2: Run All Migration Scripts (In Order!)
```sql
-- Run each script in sequence:
-- 1. Run: 001_add_source_url_display_name.sql
-- 2. Run: 002_add_hybrid_search_tsvector.sql
-- 3. Run: 003_ollama_add_columns.sql
-- 4. Run: 004_ollama_migrate_data.sql
-- 5. Run: 005_ollama_create_functions.sql
-- 6. Run: 006_ollama_create_indexes_optional.sql (optional - may timeout)
-- 7. Run: 007_add_priority_column_to_tasks.sql
-- 8. Run: 008_add_migration_tracking.sql
```
### Step 3: Restart Services
```bash
docker compose restart
```
## How to Run Migrations
### Method 1: Using Supabase Dashboard (Recommended)
1. Open your Supabase project dashboard
2. Go to **SQL Editor**
3. Copy and paste the contents of the migration file
4. Click **Run** to execute the migration
5. **Important**: Supabase only shows the result of the last query - all our scripts end with a status summary table that shows the complete results
### Method 2: Using psql Command Line
```bash
# Connect to your database
psql -h your-supabase-host -p 5432 -U postgres -d postgres
# Run the migrations in order
\i /path/to/001_add_source_url_display_name.sql
\i /path/to/002_add_hybrid_search_tsvector.sql
\i /path/to/003_ollama_add_columns.sql
\i /path/to/004_ollama_migrate_data.sql
\i /path/to/005_ollama_create_functions.sql
\i /path/to/006_ollama_create_indexes_optional.sql
\i /path/to/007_add_priority_column_to_tasks.sql
\i /path/to/008_add_migration_tracking.sql
# Exit
\q
```
### Method 3: Using Docker (if using local Supabase)
```bash
# Copy migrations to container
docker cp 001_add_source_url_display_name.sql supabase-db:/tmp/
docker cp 002_add_hybrid_search_tsvector.sql supabase-db:/tmp/
docker cp 003_ollama_add_columns.sql supabase-db:/tmp/
docker cp 004_ollama_migrate_data.sql supabase-db:/tmp/
docker cp 005_ollama_create_functions.sql supabase-db:/tmp/
docker cp 006_ollama_create_indexes_optional.sql supabase-db:/tmp/
docker cp 007_add_priority_column_to_tasks.sql supabase-db:/tmp/
docker cp 008_add_migration_tracking.sql supabase-db:/tmp/
# Execute migrations in order
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/001_add_source_url_display_name.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/002_add_hybrid_search_tsvector.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/003_ollama_add_columns.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/004_ollama_migrate_data.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/005_ollama_create_functions.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/006_ollama_create_indexes_optional.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/007_add_priority_column_to_tasks.sql
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/008_add_migration_tracking.sql
```
## Migration Safety
-**Safe to run multiple times** - Uses `IF NOT EXISTS` checks
-**Non-destructive** - Preserves all existing data
-**Automatic rollback** - Uses database transactions
-**Comprehensive logging** - Detailed progress notifications
## After Migration
1. **Restart Archon Services:**
```bash
docker-compose restart
```
2. **Verify Migration:**
- Check the Archon logs for any errors
- Try running a test crawl
- Verify search functionality works
3. **Configure New Features:**
- Go to Settings page in Archon UI
- Configure your preferred LLM and embedding models
- New crawls will automatically use model tracking

View File

@@ -1,167 +0,0 @@
# Archon Database Migrations
This folder contains database migration scripts for upgrading existing Archon installations.
## Available Migration Scripts
### 1. `backup_database.sql` - Pre-Migration Backup
**Always run this FIRST before any migration!**
Creates timestamped backup tables of all your existing data:
- ✅ Complete backup of `archon_crawled_pages`
- ✅ Complete backup of `archon_code_examples`
- ✅ Complete backup of `archon_sources`
- ✅ Easy restore commands provided
- ✅ Row count verification
### 2. `upgrade_database.sql` - Main Migration Script
**Use this migration if you:**
- Have an existing Archon installation from before multi-dimensional embedding support
- Want to upgrade to the latest features including model tracking
- Need to migrate existing embedding data to the new schema
**Features added:**
- ✅ Multi-dimensional embedding support (384, 768, 1024, 1536, 3072 dimensions)
- ✅ Model tracking fields (`llm_chat_model`, `embedding_model`, `embedding_dimension`)
- ✅ Optimized indexes for improved search performance
- ✅ Enhanced search functions with dimension-aware querying
- ✅ Automatic migration of existing embedding data
- ✅ Legacy compatibility maintained
### 3. `validate_migration.sql` - Post-Migration Validation
**Run this after the migration to verify everything worked correctly**
Validates your migration results:
- ✅ Verifies all required columns were added
- ✅ Checks that database indexes were created
- ✅ Tests that all functions are working
- ✅ Shows sample data with new fields
- ✅ Provides clear success/failure reporting
## Migration Process (Follow This Order!)
### Step 1: Backup Your Data
```sql
-- Run: backup_database.sql
-- This creates timestamped backup tables of all your data
```
### Step 2: Run the Main Migration
```sql
-- Run: upgrade_database.sql
-- This adds all the new features and migrates existing data
```
### Step 3: Validate the Results
```sql
-- Run: validate_migration.sql
-- This verifies everything worked correctly
```
### Step 4: Restart Services
```bash
docker compose restart
```
## How to Run Migrations
### Method 1: Using Supabase Dashboard (Recommended)
1. Open your Supabase project dashboard
2. Go to **SQL Editor**
3. Copy and paste the contents of the migration file
4. Click **Run** to execute the migration
5. **Important**: Supabase only shows the result of the last query - all our scripts end with a status summary table that shows the complete results
### Method 2: Using psql Command Line
```bash
# Connect to your database
psql -h your-supabase-host -p 5432 -U postgres -d postgres
# Run the migration
\i /path/to/upgrade_database.sql
# Exit
\q
```
### Method 3: Using Docker (if using local Supabase)
```bash
# Copy migration to container
docker cp upgrade_database.sql supabase-db:/tmp/
# Execute migration
docker exec -it supabase-db psql -U postgres -d postgres -f /tmp/upgrade_database.sql
```
## Migration Safety
- ✅ **Safe to run multiple times** - Uses `IF NOT EXISTS` checks
- ✅ **Non-destructive** - Preserves all existing data
- ✅ **Automatic rollback** - Uses database transactions
- ✅ **Comprehensive logging** - Detailed progress notifications
## After Migration
1. **Restart Archon Services:**
```bash
docker-compose restart
```
2. **Verify Migration:**
- Check the Archon logs for any errors
- Try running a test crawl
- Verify search functionality works
3. **Configure New Features:**
- Go to Settings page in Archon UI
- Configure your preferred LLM and embedding models
- New crawls will automatically use model tracking
## Troubleshooting
### Permission Errors
If you get permission errors, ensure your database user has sufficient privileges:
```sql
GRANT ALL PRIVILEGES ON DATABASE postgres TO your_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO your_user;
```
### Index Creation Failures
If index creation fails due to resource constraints, the migration will continue. You can create indexes manually later:
```sql
-- Example: Create missing index for 768-dimensional embeddings
CREATE INDEX idx_archon_crawled_pages_embedding_768
ON archon_crawled_pages USING ivfflat (embedding_768 vector_cosine_ops)
WITH (lists = 100);
```
### Migration Verification
Check that the migration completed successfully:
```sql
-- Verify new columns exist
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'archon_crawled_pages'
AND column_name IN ('llm_chat_model', 'embedding_model', 'embedding_dimension', 'embedding_384', 'embedding_768');
-- Verify functions exist
SELECT routine_name
FROM information_schema.routines
WHERE routine_name IN ('match_archon_crawled_pages_multi', 'detect_embedding_dimension');
```
## Support
If you encounter issues with the migration:
1. Check the console output for detailed error messages
2. Verify your database connection and permissions
3. Ensure you have sufficient disk space for index creation
4. Create a GitHub issue with the error details if problems persist
## Version Compatibility
- **Archon v2.0+**: Use `upgrade_database.sql`
- **Earlier versions**: Use `complete_setup.sql` for fresh installations
This migration is designed to bring any Archon installation up to the latest schema standards while preserving all existing data and functionality.

View File

@@ -64,6 +64,10 @@ BEGIN
DROP POLICY IF EXISTS "Allow service role full access to archon_prompts" ON archon_prompts; 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; 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) -- Legacy table policies (for migration from old schema)
DROP POLICY IF EXISTS "Allow service role full access" ON settings; DROP POLICY IF EXISTS "Allow service role full access" ON settings;
DROP POLICY IF EXISTS "Allow authenticated users to read and update" ON settings; DROP POLICY IF EXISTS "Allow authenticated users to read and update" ON settings;
@@ -175,6 +179,9 @@ BEGIN
-- Configuration System - new archon_ prefixed table -- Configuration System - new archon_ prefixed table
DROP TABLE IF EXISTS archon_settings CASCADE; 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 -- Legacy tables (without archon_ prefix) - for migration purposes
DROP TABLE IF EXISTS document_versions CASCADE; DROP TABLE IF EXISTS document_versions CASCADE;
DROP TABLE IF EXISTS project_sources CASCADE; DROP TABLE IF EXISTS project_sources CASCADE;

View File

@@ -951,6 +951,62 @@ 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.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'; 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_add_columns'),
('0.1.0', '004_ollama_migrate_data'),
('0.1.0', '005_ollama_create_functions'),
('0.1.0', '006_ollama_create_indexes_optional'),
('0.1.0', '007_add_priority_column_to_tasks'),
('0.1.0', '008_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 -- SECTION 8: PROMPTS TABLE
-- ===================================================== -- =====================================================

View File

@@ -1,518 +0,0 @@
-- ======================================================================
-- UPGRADE TO MODEL TRACKING AND MULTI-DIMENSIONAL EMBEDDINGS
-- ======================================================================
-- This migration upgrades existing Archon installations to support:
-- 1. Multi-dimensional embedding columns (768, 1024, 1536, 3072)
-- 2. Model tracking fields (llm_chat_model, embedding_model, embedding_dimension)
-- 3. 384-dimension support for smaller embedding models
-- 4. Enhanced search functions for multi-dimensional support
-- ======================================================================
--
-- IMPORTANT: Run this ONLY if you have an existing Archon installation
-- that was created BEFORE the multi-dimensional embedding support.
--
-- This script is SAFE to run multiple times - it uses IF NOT EXISTS checks.
-- ======================================================================
BEGIN;
-- ======================================================================
-- SECTION 1: ADD MULTI-DIMENSIONAL EMBEDDING COLUMNS
-- ======================================================================
-- Add multi-dimensional embedding columns to archon_crawled_pages
ALTER TABLE archon_crawled_pages
ADD COLUMN IF NOT EXISTS embedding_384 VECTOR(384), -- Small embedding models
ADD COLUMN IF NOT EXISTS embedding_768 VECTOR(768), -- Google/Ollama models
ADD COLUMN IF NOT EXISTS embedding_1024 VECTOR(1024), -- Ollama large models
ADD COLUMN IF NOT EXISTS embedding_1536 VECTOR(1536), -- OpenAI standard models
ADD COLUMN IF NOT EXISTS embedding_3072 VECTOR(3072); -- OpenAI large models
-- Add multi-dimensional embedding columns to archon_code_examples
ALTER TABLE archon_code_examples
ADD COLUMN IF NOT EXISTS embedding_384 VECTOR(384), -- Small embedding models
ADD COLUMN IF NOT EXISTS embedding_768 VECTOR(768), -- Google/Ollama models
ADD COLUMN IF NOT EXISTS embedding_1024 VECTOR(1024), -- Ollama large models
ADD COLUMN IF NOT EXISTS embedding_1536 VECTOR(1536), -- OpenAI standard models
ADD COLUMN IF NOT EXISTS embedding_3072 VECTOR(3072); -- OpenAI large models
-- ======================================================================
-- SECTION 2: ADD MODEL TRACKING COLUMNS
-- ======================================================================
-- Add model tracking columns to archon_crawled_pages
ALTER TABLE archon_crawled_pages
ADD COLUMN IF NOT EXISTS llm_chat_model TEXT, -- LLM model used for processing (e.g., 'gpt-4', 'llama3:8b')
ADD COLUMN IF NOT EXISTS embedding_model TEXT, -- Embedding model used (e.g., 'text-embedding-3-large', 'all-MiniLM-L6-v2')
ADD COLUMN IF NOT EXISTS embedding_dimension INTEGER; -- Dimension of the embedding used (384, 768, 1024, 1536, 3072)
-- Add model tracking columns to archon_code_examples
ALTER TABLE archon_code_examples
ADD COLUMN IF NOT EXISTS llm_chat_model TEXT, -- LLM model used for processing (e.g., 'gpt-4', 'llama3:8b')
ADD COLUMN IF NOT EXISTS embedding_model TEXT, -- Embedding model used (e.g., 'text-embedding-3-large', 'all-MiniLM-L6-v2')
ADD COLUMN IF NOT EXISTS embedding_dimension INTEGER; -- Dimension of the embedding used (384, 768, 1024, 1536, 3072)
-- ======================================================================
-- SECTION 3: MIGRATE EXISTING EMBEDDING DATA
-- ======================================================================
-- Check if there's existing embedding data in old 'embedding' column
DO $$
DECLARE
crawled_pages_count INTEGER;
code_examples_count INTEGER;
dimension_detected INTEGER;
BEGIN
-- Check if old embedding column exists and has data
SELECT COUNT(*) INTO crawled_pages_count
FROM information_schema.columns
WHERE table_name = 'archon_crawled_pages'
AND column_name = 'embedding';
SELECT COUNT(*) INTO code_examples_count
FROM information_schema.columns
WHERE table_name = 'archon_code_examples'
AND column_name = 'embedding';
-- If old embedding columns exist, migrate the data
IF crawled_pages_count > 0 THEN
RAISE NOTICE 'Found existing embedding column in archon_crawled_pages - migrating data...';
-- Detect dimension from first non-null embedding
SELECT vector_dims(embedding) INTO dimension_detected
FROM archon_crawled_pages
WHERE embedding IS NOT NULL
LIMIT 1;
IF dimension_detected IS NOT NULL THEN
RAISE NOTICE 'Detected embedding dimension: %', dimension_detected;
-- Migrate based on detected dimension
CASE dimension_detected
WHEN 384 THEN
UPDATE archon_crawled_pages
SET embedding_384 = embedding,
embedding_dimension = 384,
embedding_model = COALESCE(embedding_model, 'legacy-384d-model')
WHERE embedding IS NOT NULL AND embedding_384 IS NULL;
WHEN 768 THEN
UPDATE archon_crawled_pages
SET embedding_768 = embedding,
embedding_dimension = 768,
embedding_model = COALESCE(embedding_model, 'legacy-768d-model')
WHERE embedding IS NOT NULL AND embedding_768 IS NULL;
WHEN 1024 THEN
UPDATE archon_crawled_pages
SET embedding_1024 = embedding,
embedding_dimension = 1024,
embedding_model = COALESCE(embedding_model, 'legacy-1024d-model')
WHERE embedding IS NOT NULL AND embedding_1024 IS NULL;
WHEN 1536 THEN
UPDATE archon_crawled_pages
SET embedding_1536 = embedding,
embedding_dimension = 1536,
embedding_model = COALESCE(embedding_model, 'text-embedding-3-small')
WHERE embedding IS NOT NULL AND embedding_1536 IS NULL;
WHEN 3072 THEN
UPDATE archon_crawled_pages
SET embedding_3072 = embedding,
embedding_dimension = 3072,
embedding_model = COALESCE(embedding_model, 'text-embedding-3-large')
WHERE embedding IS NOT NULL AND embedding_3072 IS NULL;
ELSE
RAISE NOTICE 'Unsupported embedding dimension detected: %. Skipping migration.', dimension_detected;
END CASE;
RAISE NOTICE 'Migrated existing embeddings to dimension-specific columns';
END IF;
END IF;
-- Migrate code examples if they exist
IF code_examples_count > 0 THEN
RAISE NOTICE 'Found existing embedding column in archon_code_examples - migrating data...';
-- Detect dimension from first non-null embedding
SELECT vector_dims(embedding) INTO dimension_detected
FROM archon_code_examples
WHERE embedding IS NOT NULL
LIMIT 1;
IF dimension_detected IS NOT NULL THEN
RAISE NOTICE 'Detected code examples embedding dimension: %', dimension_detected;
-- Migrate based on detected dimension
CASE dimension_detected
WHEN 384 THEN
UPDATE archon_code_examples
SET embedding_384 = embedding,
embedding_dimension = 384,
embedding_model = COALESCE(embedding_model, 'legacy-384d-model')
WHERE embedding IS NOT NULL AND embedding_384 IS NULL;
WHEN 768 THEN
UPDATE archon_code_examples
SET embedding_768 = embedding,
embedding_dimension = 768,
embedding_model = COALESCE(embedding_model, 'legacy-768d-model')
WHERE embedding IS NOT NULL AND embedding_768 IS NULL;
WHEN 1024 THEN
UPDATE archon_code_examples
SET embedding_1024 = embedding,
embedding_dimension = 1024,
embedding_model = COALESCE(embedding_model, 'legacy-1024d-model')
WHERE embedding IS NOT NULL AND embedding_1024 IS NULL;
WHEN 1536 THEN
UPDATE archon_code_examples
SET embedding_1536 = embedding,
embedding_dimension = 1536,
embedding_model = COALESCE(embedding_model, 'text-embedding-3-small')
WHERE embedding IS NOT NULL AND embedding_1536 IS NULL;
WHEN 3072 THEN
UPDATE archon_code_examples
SET embedding_3072 = embedding,
embedding_dimension = 3072,
embedding_model = COALESCE(embedding_model, 'text-embedding-3-large')
WHERE embedding IS NOT NULL AND embedding_3072 IS NULL;
ELSE
RAISE NOTICE 'Unsupported code examples embedding dimension: %. Skipping migration.', dimension_detected;
END CASE;
RAISE NOTICE 'Migrated existing code example embeddings to dimension-specific columns';
END IF;
END IF;
END $$;
-- ======================================================================
-- SECTION 4: CLEANUP LEGACY EMBEDDING COLUMNS
-- ======================================================================
-- Remove old embedding columns after successful migration
DO $$
DECLARE
crawled_pages_count INTEGER;
code_examples_count INTEGER;
BEGIN
-- Check if old embedding column exists in crawled pages
SELECT COUNT(*) INTO crawled_pages_count
FROM information_schema.columns
WHERE table_name = 'archon_crawled_pages'
AND column_name = 'embedding';
-- Check if old embedding column exists in code examples
SELECT COUNT(*) INTO code_examples_count
FROM information_schema.columns
WHERE table_name = 'archon_code_examples'
AND column_name = 'embedding';
-- Drop old embedding column from crawled pages if it exists
IF crawled_pages_count > 0 THEN
RAISE NOTICE 'Dropping legacy embedding column from archon_crawled_pages...';
ALTER TABLE archon_crawled_pages DROP COLUMN embedding;
RAISE NOTICE 'Successfully removed legacy embedding column from archon_crawled_pages';
END IF;
-- Drop old embedding column from code examples if it exists
IF code_examples_count > 0 THEN
RAISE NOTICE 'Dropping legacy embedding column from archon_code_examples...';
ALTER TABLE archon_code_examples DROP COLUMN embedding;
RAISE NOTICE 'Successfully removed legacy embedding column from archon_code_examples';
END IF;
-- Drop any indexes on the old embedding column if they exist
DROP INDEX IF EXISTS idx_archon_crawled_pages_embedding;
DROP INDEX IF EXISTS idx_archon_code_examples_embedding;
RAISE NOTICE 'Legacy column cleanup completed';
END $$;
-- ======================================================================
-- SECTION 5: CREATE OPTIMIZED INDEXES
-- ======================================================================
-- Create indexes for archon_crawled_pages (multi-dimensional support)
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_384
ON archon_crawled_pages USING ivfflat (embedding_384 vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_768
ON archon_crawled_pages USING ivfflat (embedding_768 vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_1024
ON archon_crawled_pages USING ivfflat (embedding_1024 vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_1536
ON archon_crawled_pages USING ivfflat (embedding_1536 vector_cosine_ops)
WITH (lists = 100);
-- Note: 3072-dimensional embeddings cannot have vector indexes due to PostgreSQL vector extension 2000 dimension limit
-- The embedding_3072 column exists but cannot be indexed with current pgvector version
-- Brute force search will be used for 3072-dimensional vectors
-- CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_3072
-- ON archon_crawled_pages USING hnsw (embedding_3072 vector_cosine_ops);
-- Create indexes for archon_code_examples (multi-dimensional support)
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_384
ON archon_code_examples USING ivfflat (embedding_384 vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_768
ON archon_code_examples USING ivfflat (embedding_768 vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_1024
ON archon_code_examples USING ivfflat (embedding_1024 vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_1536
ON archon_code_examples USING ivfflat (embedding_1536 vector_cosine_ops)
WITH (lists = 100);
-- Note: 3072-dimensional embeddings cannot have vector indexes due to PostgreSQL vector extension 2000 dimension limit
-- The embedding_3072 column exists but cannot be indexed with current pgvector version
-- Brute force search will be used for 3072-dimensional vectors
-- CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_3072
-- ON archon_code_examples USING hnsw (embedding_3072 vector_cosine_ops);
-- Create indexes for model tracking columns
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_model
ON archon_crawled_pages (embedding_model);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_embedding_dimension
ON archon_crawled_pages (embedding_dimension);
CREATE INDEX IF NOT EXISTS idx_archon_crawled_pages_llm_chat_model
ON archon_crawled_pages (llm_chat_model);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_model
ON archon_code_examples (embedding_model);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_embedding_dimension
ON archon_code_examples (embedding_dimension);
CREATE INDEX IF NOT EXISTS idx_archon_code_examples_llm_chat_model
ON archon_code_examples (llm_chat_model);
-- ======================================================================
-- SECTION 6: HELPER FUNCTIONS FOR MULTI-DIMENSIONAL SUPPORT
-- ======================================================================
-- Function to detect embedding dimension from vector
CREATE OR REPLACE FUNCTION detect_embedding_dimension(embedding_vector vector)
RETURNS INTEGER AS $$
BEGIN
RETURN vector_dims(embedding_vector);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Function to get the appropriate column name for a dimension
CREATE OR REPLACE FUNCTION get_embedding_column_name(dimension INTEGER)
RETURNS TEXT AS $$
BEGIN
CASE dimension
WHEN 384 THEN RETURN 'embedding_384';
WHEN 768 THEN RETURN 'embedding_768';
WHEN 1024 THEN RETURN 'embedding_1024';
WHEN 1536 THEN RETURN 'embedding_1536';
WHEN 3072 THEN RETURN 'embedding_3072';
ELSE RAISE EXCEPTION 'Unsupported embedding dimension: %. Supported dimensions are: 384, 768, 1024, 1536, 3072', dimension;
END CASE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- ======================================================================
-- SECTION 7: ENHANCED SEARCH FUNCTIONS
-- ======================================================================
-- Create multi-dimensional function to search for documentation chunks
CREATE OR REPLACE FUNCTION match_archon_crawled_pages_multi (
query_embedding VECTOR,
embedding_dimension INTEGER,
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
#variable_conflict use_column
DECLARE
sql_query TEXT;
embedding_column TEXT;
BEGIN
-- Determine which embedding column to use based on dimension
CASE embedding_dimension
WHEN 384 THEN embedding_column := 'embedding_384';
WHEN 768 THEN embedding_column := 'embedding_768';
WHEN 1024 THEN embedding_column := 'embedding_1024';
WHEN 1536 THEN embedding_column := 'embedding_1536';
WHEN 3072 THEN embedding_column := 'embedding_3072';
ELSE RAISE EXCEPTION 'Unsupported embedding dimension: %', embedding_dimension;
END CASE;
-- Build dynamic query
sql_query := format('
SELECT id, url, chunk_number, content, metadata, source_id,
1 - (%I <=> $1) AS similarity
FROM archon_crawled_pages
WHERE (%I IS NOT NULL)
AND metadata @> $3
AND ($4 IS NULL OR source_id = $4)
ORDER BY %I <=> $1
LIMIT $2',
embedding_column, embedding_column, embedding_column);
-- Execute dynamic query
RETURN QUERY EXECUTE sql_query USING query_embedding, match_count, filter, source_filter;
END;
$$;
-- Create multi-dimensional function to search for code examples
CREATE OR REPLACE FUNCTION match_archon_code_examples_multi (
query_embedding VECTOR,
embedding_dimension INTEGER,
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
summary TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
#variable_conflict use_column
DECLARE
sql_query TEXT;
embedding_column TEXT;
BEGIN
-- Determine which embedding column to use based on dimension
CASE embedding_dimension
WHEN 384 THEN embedding_column := 'embedding_384';
WHEN 768 THEN embedding_column := 'embedding_768';
WHEN 1024 THEN embedding_column := 'embedding_1024';
WHEN 1536 THEN embedding_column := 'embedding_1536';
WHEN 3072 THEN embedding_column := 'embedding_3072';
ELSE RAISE EXCEPTION 'Unsupported embedding dimension: %', embedding_dimension;
END CASE;
-- Build dynamic query
sql_query := format('
SELECT id, url, chunk_number, content, summary, metadata, source_id,
1 - (%I <=> $1) AS similarity
FROM archon_code_examples
WHERE (%I IS NOT NULL)
AND metadata @> $3
AND ($4 IS NULL OR source_id = $4)
ORDER BY %I <=> $1
LIMIT $2',
embedding_column, embedding_column, embedding_column);
-- Execute dynamic query
RETURN QUERY EXECUTE sql_query USING query_embedding, match_count, filter, source_filter;
END;
$$;
-- ======================================================================
-- SECTION 8: LEGACY COMPATIBILITY FUNCTIONS
-- ======================================================================
-- Legacy compatibility function for crawled pages (defaults to 1536D)
CREATE OR REPLACE FUNCTION match_archon_crawled_pages (
query_embedding VECTOR(1536),
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT * FROM match_archon_crawled_pages_multi(query_embedding, 1536, match_count, filter, source_filter);
END;
$$;
-- Legacy compatibility function for code examples (defaults to 1536D)
CREATE OR REPLACE FUNCTION match_archon_code_examples (
query_embedding VECTOR(1536),
match_count INT DEFAULT 10,
filter JSONB DEFAULT '{}'::jsonb,
source_filter TEXT DEFAULT NULL
) RETURNS TABLE (
id BIGINT,
url VARCHAR,
chunk_number INTEGER,
content TEXT,
summary TEXT,
metadata JSONB,
source_id TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT * FROM match_archon_code_examples_multi(query_embedding, 1536, match_count, filter, source_filter);
END;
$$;
COMMIT;
-- ======================================================================
-- MIGRATION COMPLETE - SUPABASE-FRIENDLY STATUS REPORT
-- ======================================================================
-- This final SELECT statement consolidates all status information for
-- display in Supabase SQL Editor (users only see the last query result)
SELECT
'🎉 ARCHON MODEL TRACKING UPGRADE COMPLETED! 🎉' AS status,
'Successfully upgraded your Archon installation' AS message,
ARRAY[
'✅ Multi-dimensional embedding support (384, 768, 1024, 1536, 3072)',
'✅ Model tracking fields (llm_chat_model, embedding_model, embedding_dimension)',
'✅ Optimized indexes for improved search performance',
'✅ Enhanced search functions with dimension-aware querying',
'✅ Legacy compatibility maintained for existing code',
'✅ Existing embedding data migrated (if any was found)',
'✅ Support for 3072-dimensional vectors (using brute force search)'
] AS features_added,
ARRAY[
'• Multiple embedding providers (OpenAI, Ollama, Google, etc.)',
'• Automatic model detection and tracking',
'• Improved search accuracy with dimension-specific indexing',
'• Full audit trail of which models processed your data'
] AS capabilities_enabled,
ARRAY[
'1. Restart your Archon services: docker compose restart',
'2. New crawls will automatically use the enhanced features',
'3. Check the Settings page to configure your preferred models',
'4. Run validate_migration.sql to verify everything works'
] AS next_steps;

View File

@@ -1,287 +0,0 @@
-- ======================================================================
-- ARCHON MIGRATION VALIDATION SCRIPT
-- ======================================================================
-- This script validates that the upgrade_to_model_tracking.sql migration
-- completed successfully and all features are working.
-- ======================================================================
DO $$
DECLARE
crawled_pages_columns INTEGER := 0;
code_examples_columns INTEGER := 0;
crawled_pages_indexes INTEGER := 0;
code_examples_indexes INTEGER := 0;
functions_count INTEGER := 0;
migration_success BOOLEAN := TRUE;
error_messages TEXT := '';
BEGIN
RAISE NOTICE '====================================================================';
RAISE NOTICE ' VALIDATING ARCHON MIGRATION RESULTS';
RAISE NOTICE '====================================================================';
-- Check if required columns exist in archon_crawled_pages
SELECT COUNT(*) INTO crawled_pages_columns
FROM information_schema.columns
WHERE table_name = 'archon_crawled_pages'
AND column_name IN (
'embedding_384', 'embedding_768', 'embedding_1024', 'embedding_1536', 'embedding_3072',
'llm_chat_model', 'embedding_model', 'embedding_dimension'
);
-- Check if required columns exist in archon_code_examples
SELECT COUNT(*) INTO code_examples_columns
FROM information_schema.columns
WHERE table_name = 'archon_code_examples'
AND column_name IN (
'embedding_384', 'embedding_768', 'embedding_1024', 'embedding_1536', 'embedding_3072',
'llm_chat_model', 'embedding_model', 'embedding_dimension'
);
-- Check if indexes were created for archon_crawled_pages
SELECT COUNT(*) INTO crawled_pages_indexes
FROM pg_indexes
WHERE tablename = 'archon_crawled_pages'
AND indexname IN (
'idx_archon_crawled_pages_embedding_384',
'idx_archon_crawled_pages_embedding_768',
'idx_archon_crawled_pages_embedding_1024',
'idx_archon_crawled_pages_embedding_1536',
'idx_archon_crawled_pages_embedding_model',
'idx_archon_crawled_pages_embedding_dimension',
'idx_archon_crawled_pages_llm_chat_model'
);
-- Check if indexes were created for archon_code_examples
SELECT COUNT(*) INTO code_examples_indexes
FROM pg_indexes
WHERE tablename = 'archon_code_examples'
AND indexname IN (
'idx_archon_code_examples_embedding_384',
'idx_archon_code_examples_embedding_768',
'idx_archon_code_examples_embedding_1024',
'idx_archon_code_examples_embedding_1536',
'idx_archon_code_examples_embedding_model',
'idx_archon_code_examples_embedding_dimension',
'idx_archon_code_examples_llm_chat_model'
);
-- Check if required functions exist
SELECT COUNT(*) INTO functions_count
FROM information_schema.routines
WHERE routine_name IN (
'match_archon_crawled_pages_multi',
'match_archon_code_examples_multi',
'detect_embedding_dimension',
'get_embedding_column_name'
);
-- Validate results
RAISE NOTICE 'COLUMN VALIDATION:';
IF crawled_pages_columns = 8 THEN
RAISE NOTICE '✅ archon_crawled_pages: All 8 required columns found';
ELSE
RAISE NOTICE '❌ archon_crawled_pages: Expected 8 columns, found %', crawled_pages_columns;
migration_success := FALSE;
error_messages := error_messages || '• Missing columns in archon_crawled_pages' || chr(10);
END IF;
IF code_examples_columns = 8 THEN
RAISE NOTICE '✅ archon_code_examples: All 8 required columns found';
ELSE
RAISE NOTICE '❌ archon_code_examples: Expected 8 columns, found %', code_examples_columns;
migration_success := FALSE;
error_messages := error_messages || '• Missing columns in archon_code_examples' || chr(10);
END IF;
RAISE NOTICE '';
RAISE NOTICE 'INDEX VALIDATION:';
IF crawled_pages_indexes >= 6 THEN
RAISE NOTICE '✅ archon_crawled_pages: % indexes created (expected 6+)', crawled_pages_indexes;
ELSE
RAISE NOTICE '⚠️ archon_crawled_pages: % indexes created (expected 6+)', crawled_pages_indexes;
RAISE NOTICE ' Note: Some indexes may have failed due to resource constraints - this is OK';
END IF;
IF code_examples_indexes >= 6 THEN
RAISE NOTICE '✅ archon_code_examples: % indexes created (expected 6+)', code_examples_indexes;
ELSE
RAISE NOTICE '⚠️ archon_code_examples: % indexes created (expected 6+)', code_examples_indexes;
RAISE NOTICE ' Note: Some indexes may have failed due to resource constraints - this is OK';
END IF;
RAISE NOTICE '';
RAISE NOTICE 'FUNCTION VALIDATION:';
IF functions_count = 4 THEN
RAISE NOTICE '✅ All 4 required functions created successfully';
ELSE
RAISE NOTICE '❌ Expected 4 functions, found %', functions_count;
migration_success := FALSE;
error_messages := error_messages || '• Missing database functions' || chr(10);
END IF;
-- Test function functionality
BEGIN
PERFORM detect_embedding_dimension(ARRAY[1,2,3]::vector);
RAISE NOTICE '✅ detect_embedding_dimension function working';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE '❌ detect_embedding_dimension function failed: %', SQLERRM;
migration_success := FALSE;
error_messages := error_messages || '• detect_embedding_dimension function not working' || chr(10);
END;
BEGIN
PERFORM get_embedding_column_name(1536);
RAISE NOTICE '✅ get_embedding_column_name function working';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE '❌ get_embedding_column_name function failed: %', SQLERRM;
migration_success := FALSE;
error_messages := error_messages || '• get_embedding_column_name function not working' || chr(10);
END;
RAISE NOTICE '';
RAISE NOTICE '====================================================================';
IF migration_success THEN
RAISE NOTICE '🎉 MIGRATION VALIDATION SUCCESSFUL!';
RAISE NOTICE '';
RAISE NOTICE 'Your Archon installation has been successfully upgraded with:';
RAISE NOTICE '✅ Multi-dimensional embedding support';
RAISE NOTICE '✅ Model tracking capabilities';
RAISE NOTICE '✅ Enhanced search functions';
RAISE NOTICE '✅ Optimized database indexes';
RAISE NOTICE '';
RAISE NOTICE 'Next steps:';
RAISE NOTICE '1. Restart your Archon services: docker compose restart';
RAISE NOTICE '2. Test with a small crawl to verify functionality';
RAISE NOTICE '3. Configure your preferred models in Settings';
ELSE
RAISE NOTICE '❌ MIGRATION VALIDATION FAILED!';
RAISE NOTICE '';
RAISE NOTICE 'Issues found:';
RAISE NOTICE '%', error_messages;
RAISE NOTICE 'Please check the migration logs and re-run if necessary.';
END IF;
RAISE NOTICE '====================================================================';
-- Show sample of existing data if any
DECLARE
sample_count INTEGER;
r RECORD; -- Declare the loop variable as RECORD type
BEGIN
SELECT COUNT(*) INTO sample_count FROM archon_crawled_pages LIMIT 1;
IF sample_count > 0 THEN
RAISE NOTICE '';
RAISE NOTICE 'SAMPLE DATA CHECK:';
-- Show one record with the new columns
FOR r IN
SELECT url, embedding_model, embedding_dimension,
CASE WHEN llm_chat_model IS NOT NULL THEN '' ELSE '' END as llm_status,
CASE WHEN embedding_384 IS NOT NULL THEN '✅ 384'
WHEN embedding_768 IS NOT NULL THEN '✅ 768'
WHEN embedding_1024 IS NOT NULL THEN '✅ 1024'
WHEN embedding_1536 IS NOT NULL THEN '✅ 1536'
WHEN embedding_3072 IS NOT NULL THEN '✅ 3072'
ELSE '⚪ None' END as embedding_status
FROM archon_crawled_pages
LIMIT 3
LOOP
RAISE NOTICE 'Record: % | Model: % | Dimension: % | LLM: % | Embedding: %',
substring(r.url from 1 for 40),
COALESCE(r.embedding_model, 'None'),
COALESCE(r.embedding_dimension::text, 'None'),
r.llm_status,
r.embedding_status;
END LOOP;
END IF;
END;
END $$;
-- ======================================================================
-- VALIDATION COMPLETE - SUPABASE-FRIENDLY STATUS REPORT
-- ======================================================================
-- This final SELECT statement consolidates validation results for
-- display in Supabase SQL Editor (users only see the last query result)
WITH validation_results AS (
-- Check if all required columns exist
SELECT
COUNT(*) FILTER (WHERE column_name IN ('embedding_384', 'embedding_768', 'embedding_1024', 'embedding_1536', 'embedding_3072')) as embedding_columns,
COUNT(*) FILTER (WHERE column_name IN ('llm_chat_model', 'embedding_model', 'embedding_dimension')) as tracking_columns
FROM information_schema.columns
WHERE table_name = 'archon_crawled_pages'
),
function_check AS (
-- Check if required functions exist
SELECT
COUNT(*) FILTER (WHERE routine_name IN ('match_archon_crawled_pages_multi', 'match_archon_code_examples_multi', 'detect_embedding_dimension', 'get_embedding_column_name')) as functions_count
FROM information_schema.routines
WHERE routine_type = 'FUNCTION'
),
index_check AS (
-- Check if indexes exist
SELECT
COUNT(*) FILTER (WHERE indexname LIKE '%embedding_%') as embedding_indexes
FROM pg_indexes
WHERE tablename IN ('archon_crawled_pages', 'archon_code_examples')
),
data_sample AS (
-- Get sample of data with new columns
SELECT
COUNT(*) as total_records,
COUNT(*) FILTER (WHERE embedding_model IS NOT NULL) as records_with_model_tracking,
COUNT(*) FILTER (WHERE embedding_384 IS NOT NULL OR embedding_768 IS NOT NULL OR embedding_1024 IS NOT NULL OR embedding_1536 IS NOT NULL OR embedding_3072 IS NOT NULL) as records_with_multi_dim_embeddings
FROM archon_crawled_pages
),
overall_status AS (
SELECT
CASE
WHEN v.embedding_columns = 5 AND v.tracking_columns = 3 AND f.functions_count >= 4 AND i.embedding_indexes > 0
THEN '✅ MIGRATION VALIDATION SUCCESSFUL!'
ELSE '❌ MIGRATION VALIDATION FAILED!'
END as status,
v.embedding_columns,
v.tracking_columns,
f.functions_count,
i.embedding_indexes,
d.total_records,
d.records_with_model_tracking,
d.records_with_multi_dim_embeddings
FROM validation_results v, function_check f, index_check i, data_sample d
)
SELECT
status,
CASE
WHEN embedding_columns = 5 AND tracking_columns = 3 AND functions_count >= 4 AND embedding_indexes > 0
THEN 'All validation checks passed successfully'
ELSE 'Some validation checks failed - please review the results'
END as message,
json_build_object(
'embedding_columns_added', embedding_columns || '/5',
'tracking_columns_added', tracking_columns || '/3',
'search_functions_created', functions_count || '+ functions',
'embedding_indexes_created', embedding_indexes || '+ indexes'
) as technical_validation,
json_build_object(
'total_records', total_records,
'records_with_model_tracking', records_with_model_tracking,
'records_with_multi_dimensional_embeddings', records_with_multi_dim_embeddings
) as data_status,
CASE
WHEN embedding_columns = 5 AND tracking_columns = 3 AND functions_count >= 4 AND embedding_indexes > 0
THEN ARRAY[
'1. Restart Archon services: docker compose restart',
'2. Test with a small crawl to verify functionality',
'3. Configure your preferred models in Settings',
'4. New crawls will automatically use model tracking'
]
ELSE ARRAY[
'1. Check migration logs for specific errors',
'2. Re-run upgrade_database.sql if needed',
'3. Ensure database has sufficient permissions',
'4. Contact support if issues persist'
]
END as next_steps
FROM overall_status;

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,10 +23,12 @@ 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.internal_api import router as internal_router
from .api_routes.knowledge_api import router as knowledge_router from .api_routes.knowledge_api import router as knowledge_router
from .api_routes.mcp_api import router as mcp_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.ollama_api import router as ollama_router
from .api_routes.progress_api import router as progress_router from .api_routes.progress_api import router as progress_router
from .api_routes.projects_api import router as projects_router from .api_routes.projects_api import router as projects_router
from .api_routes.providers_api import router as providers_router from .api_routes.providers_api import router as providers_router
from .api_routes.version_api import router as version_router
# Import modular API routers # Import modular API routers
from .api_routes.settings_api import router as settings_router from .api_routes.settings_api import router as settings_router
@@ -188,6 +190,8 @@ app.include_router(agent_chat_router)
app.include_router(internal_router) app.include_router(internal_router)
app.include_router(bug_report_router) app.include_router(bug_report_router)
app.include_router(providers_router) app.include_router(providers_router)
app.include_router(version_router)
app.include_router(migration_router)
# Root endpoint # 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

View File

@@ -0,0 +1,206 @@
"""
Unit tests for migration_api.py
"""
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from src.server.config.version import ARCHON_VERSION
from src.server.main import app
from src.server.services.migration_service import MigrationRecord, PendingMigration
@pytest.fixture
def client():
"""Create test client."""
return TestClient(app)
@pytest.fixture
def mock_applied_migrations():
"""Mock applied migration data."""
return [
MigrationRecord({
"version": "0.1.0",
"migration_name": "001_initial",
"applied_at": datetime(2025, 1, 1, 0, 0, 0),
"checksum": "abc123",
}),
MigrationRecord({
"version": "0.1.0",
"migration_name": "002_add_column",
"applied_at": datetime(2025, 1, 2, 0, 0, 0),
"checksum": "def456",
}),
]
@pytest.fixture
def mock_pending_migrations():
"""Mock pending migration data."""
return [
PendingMigration(
version="0.1.0",
name="003_add_index",
sql_content="CREATE INDEX idx_test ON test_table(name);",
file_path="migration/0.1.0/003_add_index.sql"
),
PendingMigration(
version="0.1.0",
name="004_add_table",
sql_content="CREATE TABLE new_table (id INT);",
file_path="migration/0.1.0/004_add_table.sql"
),
]
@pytest.fixture
def mock_migration_status(mock_applied_migrations, mock_pending_migrations):
"""Mock complete migration status."""
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 mock_pending_migrations
],
"applied_migrations": [
{"version": m.version, "migration_name": m.migration_name, "applied_at": m.applied_at, "checksum": m.checksum}
for m in mock_applied_migrations
],
"has_pending": True,
"bootstrap_required": False,
"current_version": ARCHON_VERSION,
"pending_count": 2,
"applied_count": 2,
}
def test_get_migration_status_success(client, mock_migration_status):
"""Test successful migration status retrieval."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_migration_status = AsyncMock(return_value=mock_migration_status)
response = client.get("/api/migrations/status")
assert response.status_code == 200
data = response.json()
assert data["current_version"] == ARCHON_VERSION
assert data["has_pending"] is True
assert data["bootstrap_required"] is False
assert data["pending_count"] == 2
assert data["applied_count"] == 2
assert len(data["pending_migrations"]) == 2
assert len(data["applied_migrations"]) == 2
def test_get_migration_status_bootstrap_required(client):
"""Test migration status when bootstrap is required."""
mock_status = {
"pending_migrations": [],
"applied_migrations": [],
"has_pending": True,
"bootstrap_required": True,
"current_version": ARCHON_VERSION,
"pending_count": 5,
"applied_count": 0,
}
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_migration_status = AsyncMock(return_value=mock_status)
response = client.get("/api/migrations/status")
assert response.status_code == 200
data = response.json()
assert data["bootstrap_required"] is True
assert data["applied_count"] == 0
def test_get_migration_status_error(client):
"""Test error handling in migration status."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_migration_status = AsyncMock(side_effect=Exception("Database error"))
response = client.get("/api/migrations/status")
assert response.status_code == 500
assert "Failed to get migration status" in response.json()["detail"]
def test_get_migration_history_success(client, mock_applied_migrations):
"""Test successful migration history retrieval."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_applied_migrations = AsyncMock(return_value=mock_applied_migrations)
response = client.get("/api/migrations/history")
assert response.status_code == 200
data = response.json()
assert data["total_count"] == 2
assert data["current_version"] == ARCHON_VERSION
assert len(data["migrations"]) == 2
assert data["migrations"][0]["migration_name"] == "001_initial"
def test_get_migration_history_empty(client):
"""Test migration history when no migrations applied."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_applied_migrations = AsyncMock(return_value=[])
response = client.get("/api/migrations/history")
assert response.status_code == 200
data = response.json()
assert data["total_count"] == 0
assert len(data["migrations"]) == 0
def test_get_migration_history_error(client):
"""Test error handling in migration history."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_applied_migrations = AsyncMock(side_effect=Exception("Database error"))
response = client.get("/api/migrations/history")
assert response.status_code == 500
assert "Failed to get migration history" in response.json()["detail"]
def test_get_pending_migrations_success(client, mock_pending_migrations):
"""Test successful pending migrations retrieval."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_pending_migrations = AsyncMock(return_value=mock_pending_migrations)
response = client.get("/api/migrations/pending")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["name"] == "003_add_index"
assert data[0]["sql_content"] == "CREATE INDEX idx_test ON test_table(name);"
assert data[1]["name"] == "004_add_table"
def test_get_pending_migrations_none(client):
"""Test when no pending migrations exist."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_pending_migrations = AsyncMock(return_value=[])
response = client.get("/api/migrations/pending")
assert response.status_code == 200
data = response.json()
assert len(data) == 0
def test_get_pending_migrations_error(client):
"""Test error handling in pending migrations."""
with patch("src.server.api_routes.migration_api.migration_service") as mock_service:
mock_service.get_pending_migrations = AsyncMock(side_effect=Exception("File error"))
response = client.get("/api/migrations/pending")
assert response.status_code == 500
assert "Failed to get pending migrations" in response.json()["detail"]

View File

@@ -0,0 +1,147 @@
"""
Unit tests for version_api.py
"""
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from src.server.config.version import ARCHON_VERSION
from src.server.main import app
@pytest.fixture
def client():
"""Create test client."""
return TestClient(app)
@pytest.fixture
def mock_version_data():
"""Mock version check data."""
return {
"current": ARCHON_VERSION,
"latest": "0.2.0",
"update_available": True,
"release_url": "https://github.com/coleam00/Archon/releases/tag/v0.2.0",
"release_notes": "New features and bug fixes",
"published_at": datetime(2025, 1, 1, 0, 0, 0),
"check_error": None,
"author": "coleam00",
"assets": [{"name": "archon.zip", "size": 1024000}],
}
def test_check_for_updates_success(client, mock_version_data):
"""Test successful version check."""
with patch("src.server.api_routes.version_api.version_service") as mock_service:
mock_service.check_for_updates = AsyncMock(return_value=mock_version_data)
response = client.get("/api/version/check")
assert response.status_code == 200
data = response.json()
assert data["current"] == ARCHON_VERSION
assert data["latest"] == "0.2.0"
assert data["update_available"] is True
assert data["release_url"] == mock_version_data["release_url"]
def test_check_for_updates_no_update(client):
"""Test when no update is available."""
mock_data = {
"current": ARCHON_VERSION,
"latest": ARCHON_VERSION,
"update_available": False,
"release_url": None,
"release_notes": None,
"published_at": None,
"check_error": None,
}
with patch("src.server.api_routes.version_api.version_service") as mock_service:
mock_service.check_for_updates = AsyncMock(return_value=mock_data)
response = client.get("/api/version/check")
assert response.status_code == 200
data = response.json()
assert data["current"] == ARCHON_VERSION
assert data["latest"] == ARCHON_VERSION
assert data["update_available"] is False
def test_check_for_updates_with_etag_modified(client, mock_version_data):
"""Test ETag handling when data has changed."""
with patch("src.server.api_routes.version_api.version_service") as mock_service:
mock_service.check_for_updates = AsyncMock(return_value=mock_version_data)
# First request
response1 = client.get("/api/version/check")
assert response1.status_code == 200
old_etag = response1.headers.get("etag")
# Modify data
modified_data = mock_version_data.copy()
modified_data["latest"] = "0.3.0"
mock_service.check_for_updates = AsyncMock(return_value=modified_data)
# Second request with old ETag
response2 = client.get("/api/version/check", headers={"If-None-Match": old_etag})
assert response2.status_code == 200 # Data changed, return new data
data = response2.json()
assert data["latest"] == "0.3.0"
def test_check_for_updates_error_handling(client):
"""Test error handling in version check."""
with patch("src.server.api_routes.version_api.version_service") as mock_service:
mock_service.check_for_updates = AsyncMock(side_effect=Exception("API error"))
response = client.get("/api/version/check")
assert response.status_code == 200 # Should still return 200
data = response.json()
assert data["current"] == ARCHON_VERSION
assert data["latest"] is None
assert data["update_available"] is False
assert data["check_error"] == "API error"
def test_get_current_version(client):
"""Test getting current version."""
response = client.get("/api/version/current")
assert response.status_code == 200
data = response.json()
assert data["version"] == ARCHON_VERSION
assert "timestamp" in data
def test_clear_version_cache_success(client):
"""Test clearing version cache."""
with patch("src.server.api_routes.version_api.version_service") as mock_service:
mock_service.clear_cache.return_value = None
response = client.post("/api/version/clear-cache")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "Version cache cleared successfully"
mock_service.clear_cache.assert_called_once()
def test_clear_version_cache_error(client):
"""Test error handling when clearing cache fails."""
with patch("src.server.api_routes.version_api.version_service") as mock_service:
mock_service.clear_cache.side_effect = Exception("Cache error")
response = client.post("/api/version/clear-cache")
assert response.status_code == 500
assert "Failed to clear cache" in response.json()["detail"]

View File

@@ -0,0 +1,271 @@
"""
Fixed unit tests for migration_service.py
"""
import hashlib
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from src.server.config.version import ARCHON_VERSION
from src.server.services.migration_service import (
MigrationRecord,
MigrationService,
PendingMigration,
)
@pytest.fixture
def migration_service():
"""Create a migration service instance."""
with patch("src.server.services.migration_service.Path.exists") as mock_exists:
# Mock that migration directory exists locally
mock_exists.return_value = False # Docker path doesn't exist
service = MigrationService()
return service
@pytest.fixture
def mock_supabase_client():
"""Mock Supabase client."""
client = MagicMock()
return client
def test_pending_migration_init():
"""Test PendingMigration initialization and checksum calculation."""
migration = PendingMigration(
version="0.1.0",
name="001_initial",
sql_content="CREATE TABLE test (id INT);",
file_path="migration/0.1.0/001_initial.sql"
)
assert migration.version == "0.1.0"
assert migration.name == "001_initial"
assert migration.sql_content == "CREATE TABLE test (id INT);"
assert migration.file_path == "migration/0.1.0/001_initial.sql"
assert migration.checksum == hashlib.md5("CREATE TABLE test (id INT);".encode()).hexdigest()
def test_migration_record_init():
"""Test MigrationRecord initialization from database data."""
data = {
"id": "123-456",
"version": "0.1.0",
"migration_name": "001_initial",
"applied_at": "2025-01-01T00:00:00Z",
"checksum": "abc123"
}
record = MigrationRecord(data)
assert record.id == "123-456"
assert record.version == "0.1.0"
assert record.migration_name == "001_initial"
assert record.applied_at == "2025-01-01T00:00:00Z"
assert record.checksum == "abc123"
def test_migration_service_init_local():
"""Test MigrationService initialization with local path."""
with patch("src.server.services.migration_service.Path.exists") as mock_exists:
# Mock that Docker path doesn't exist
mock_exists.return_value = False
service = MigrationService()
assert service._migrations_dir == Path("migration")
def test_migration_service_init_docker():
"""Test MigrationService initialization with Docker path."""
with patch("src.server.services.migration_service.Path.exists") as mock_exists:
# Mock that Docker path exists
mock_exists.return_value = True
service = MigrationService()
assert service._migrations_dir == Path("/app/migration")
@pytest.mark.asyncio
async def test_get_applied_migrations_success(migration_service, mock_supabase_client):
"""Test successful retrieval of applied migrations."""
mock_response = MagicMock()
mock_response.data = [
{
"id": "123",
"version": "0.1.0",
"migration_name": "001_initial",
"applied_at": "2025-01-01T00:00:00Z",
"checksum": "abc123",
},
]
mock_supabase_client.table.return_value.select.return_value.order.return_value.execute.return_value = mock_response
with patch.object(migration_service, '_get_supabase_client', return_value=mock_supabase_client):
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
result = await migration_service.get_applied_migrations()
assert len(result) == 1
assert isinstance(result[0], MigrationRecord)
assert result[0].version == "0.1.0"
assert result[0].migration_name == "001_initial"
@pytest.mark.asyncio
async def test_get_applied_migrations_table_not_exists(migration_service, mock_supabase_client):
"""Test handling when migrations table doesn't exist."""
with patch.object(migration_service, '_get_supabase_client', return_value=mock_supabase_client):
with patch.object(migration_service, 'check_migrations_table_exists', return_value=False):
result = await migration_service.get_applied_migrations()
assert result == []
@pytest.mark.asyncio
async def test_get_pending_migrations_with_files(migration_service, mock_supabase_client):
"""Test getting pending migrations from filesystem."""
# Mock scan_migration_directory to return test migrations
mock_migrations = [
PendingMigration(
version="0.1.0",
name="001_initial",
sql_content="CREATE TABLE test;",
file_path="migration/0.1.0/001_initial.sql"
),
PendingMigration(
version="0.1.0",
name="002_update",
sql_content="ALTER TABLE test ADD col TEXT;",
file_path="migration/0.1.0/002_update.sql"
)
]
# Mock no applied migrations
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_migrations):
with patch.object(migration_service, 'get_applied_migrations', return_value=[]):
result = await migration_service.get_pending_migrations()
assert len(result) == 2
assert all(isinstance(m, PendingMigration) for m in result)
assert result[0].name == "001_initial"
assert result[1].name == "002_update"
@pytest.mark.asyncio
async def test_get_pending_migrations_some_applied(migration_service, mock_supabase_client):
"""Test getting pending migrations when some are already applied."""
# Mock all migrations
mock_all_migrations = [
PendingMigration(
version="0.1.0",
name="001_initial",
sql_content="CREATE TABLE test;",
file_path="migration/0.1.0/001_initial.sql"
),
PendingMigration(
version="0.1.0",
name="002_update",
sql_content="ALTER TABLE test ADD col TEXT;",
file_path="migration/0.1.0/002_update.sql"
)
]
# Mock first migration as applied
mock_applied = [
MigrationRecord({
"version": "0.1.0",
"migration_name": "001_initial",
"applied_at": "2025-01-01T00:00:00Z",
"checksum": None
})
]
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_all_migrations):
with patch.object(migration_service, 'get_applied_migrations', return_value=mock_applied):
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
result = await migration_service.get_pending_migrations()
assert len(result) == 1
assert result[0].name == "002_update"
@pytest.mark.asyncio
async def test_get_migration_status_all_applied(migration_service, mock_supabase_client):
"""Test migration status when all migrations are applied."""
# Mock one migration file
mock_all_migrations = [
PendingMigration(
version="0.1.0",
name="001_initial",
sql_content="CREATE TABLE test;",
file_path="migration/0.1.0/001_initial.sql"
)
]
# Mock migration as applied
mock_applied = [
MigrationRecord({
"version": "0.1.0",
"migration_name": "001_initial",
"applied_at": "2025-01-01T00:00:00Z",
"checksum": None
})
]
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_all_migrations):
with patch.object(migration_service, 'get_applied_migrations', return_value=mock_applied):
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
result = await migration_service.get_migration_status()
assert result["current_version"] == ARCHON_VERSION
assert result["has_pending"] is False
assert result["bootstrap_required"] is False
assert result["pending_count"] == 0
assert result["applied_count"] == 1
@pytest.mark.asyncio
async def test_get_migration_status_bootstrap_required(migration_service, mock_supabase_client):
"""Test migration status when bootstrap is required (table doesn't exist)."""
# Mock migration files
mock_all_migrations = [
PendingMigration(
version="0.1.0",
name="001_initial",
sql_content="CREATE TABLE test;",
file_path="migration/0.1.0/001_initial.sql"
),
PendingMigration(
version="0.1.0",
name="002_update",
sql_content="ALTER TABLE test ADD col TEXT;",
file_path="migration/0.1.0/002_update.sql"
)
]
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_all_migrations):
with patch.object(migration_service, 'get_applied_migrations', return_value=[]):
with patch.object(migration_service, 'check_migrations_table_exists', return_value=False):
result = await migration_service.get_migration_status()
assert result["bootstrap_required"] is True
assert result["has_pending"] is True
assert result["pending_count"] == 2
assert result["applied_count"] == 0
assert len(result["pending_migrations"]) == 2
@pytest.mark.asyncio
async def test_get_migration_status_no_files(migration_service, mock_supabase_client):
"""Test migration status when no migration files exist."""
with patch.object(migration_service, 'scan_migration_directory', return_value=[]):
with patch.object(migration_service, 'get_applied_migrations', return_value=[]):
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
result = await migration_service.get_migration_status()
assert result["has_pending"] is False
assert result["pending_count"] == 0
assert len(result["pending_migrations"]) == 0

View File

@@ -0,0 +1,234 @@
"""
Unit tests for version_service.py
"""
import json
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from src.server.config.version import ARCHON_VERSION
from src.server.services.version_service import VersionService
@pytest.fixture
def version_service():
"""Create a fresh version service instance for each test."""
service = VersionService()
# Clear any cache from previous tests
service._cache = None
service._cache_time = None
return service
@pytest.fixture
def mock_release_data():
"""Mock GitHub release data."""
return {
"tag_name": "v0.2.0",
"name": "Archon v0.2.0",
"html_url": "https://github.com/coleam00/Archon/releases/tag/v0.2.0",
"body": "## Release Notes\n\nNew features and bug fixes",
"published_at": "2025-01-01T00:00:00Z",
"author": {"login": "coleam00"},
"assets": [
{
"name": "archon-v0.2.0.zip",
"size": 1024000,
"download_count": 100,
"browser_download_url": "https://github.com/coleam00/Archon/releases/download/v0.2.0/archon-v0.2.0.zip",
"content_type": "application/zip",
}
],
}
@pytest.mark.asyncio
async def test_get_latest_release_success(version_service, mock_release_data):
"""Test successful fetching of latest release from GitHub."""
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_release_data
mock_client.get.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
assert result == mock_release_data
assert version_service._cache == mock_release_data
assert version_service._cache_time is not None
@pytest.mark.asyncio
async def test_get_latest_release_uses_cache(version_service, mock_release_data):
"""Test that cache is used when available and not expired."""
# Set up cache
version_service._cache = mock_release_data
version_service._cache_time = datetime.now()
with patch("httpx.AsyncClient") as mock_client_class:
result = await version_service.get_latest_release()
# Should not make HTTP request
mock_client_class.assert_not_called()
assert result == mock_release_data
@pytest.mark.asyncio
async def test_get_latest_release_cache_expired(version_service, mock_release_data):
"""Test that cache is refreshed when expired."""
# Set up expired cache
old_data = {"tag_name": "v0.1.0"}
version_service._cache = old_data
version_service._cache_time = datetime.now() - timedelta(hours=2)
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_release_data
mock_client.get.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
# Should make new HTTP request
mock_client.get.assert_called_once()
assert result == mock_release_data
assert version_service._cache == mock_release_data
@pytest.mark.asyncio
async def test_get_latest_release_404(version_service):
"""Test handling of 404 (no releases)."""
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 404
mock_client.get.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
assert result is None
@pytest.mark.asyncio
async def test_get_latest_release_timeout(version_service, mock_release_data):
"""Test handling of timeout with cache fallback."""
# Set up cache
version_service._cache = mock_release_data
version_service._cache_time = datetime.now() - timedelta(hours=2) # Expired
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.TimeoutException("Timeout")
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
# Should return cached data
assert result == mock_release_data
@pytest.mark.asyncio
async def test_check_for_updates_new_version_available(version_service, mock_release_data):
"""Test when a new version is available."""
with patch.object(version_service, "get_latest_release", return_value=mock_release_data):
result = await version_service.check_for_updates()
assert result["current"] == ARCHON_VERSION
assert result["latest"] == "0.2.0"
assert result["update_available"] is True
assert result["release_url"] == mock_release_data["html_url"]
assert result["release_notes"] == mock_release_data["body"]
assert result["published_at"] == datetime.fromisoformat("2025-01-01T00:00:00+00:00")
assert result["author"] == "coleam00"
assert len(result["assets"]) == 1
@pytest.mark.asyncio
async def test_check_for_updates_same_version(version_service):
"""Test when current version is up to date."""
mock_data = {"tag_name": f"v{ARCHON_VERSION}", "html_url": "test_url", "body": "notes"}
with patch.object(version_service, "get_latest_release", return_value=mock_data):
result = await version_service.check_for_updates()
assert result["current"] == ARCHON_VERSION
assert result["latest"] == ARCHON_VERSION
assert result["update_available"] is False
@pytest.mark.asyncio
async def test_check_for_updates_no_release(version_service):
"""Test when no releases are found."""
with patch.object(version_service, "get_latest_release", return_value=None):
result = await version_service.check_for_updates()
assert result["current"] == ARCHON_VERSION
assert result["latest"] is None
assert result["update_available"] is False
assert result["release_url"] is None
@pytest.mark.asyncio
async def test_check_for_updates_parse_version(version_service, mock_release_data):
"""Test version parsing with and without 'v' prefix."""
# Test with 'v' prefix
mock_release_data["tag_name"] = "v1.2.3"
with patch.object(version_service, "get_latest_release", return_value=mock_release_data):
result = await version_service.check_for_updates()
assert result["latest"] == "1.2.3"
# Test without 'v' prefix
mock_release_data["tag_name"] = "2.0.0"
with patch.object(version_service, "get_latest_release", return_value=mock_release_data):
result = await version_service.check_for_updates()
assert result["latest"] == "2.0.0"
@pytest.mark.asyncio
async def test_check_for_updates_missing_fields(version_service):
"""Test handling of incomplete release data."""
mock_data = {"tag_name": "v0.2.0"} # Minimal data
with patch.object(version_service, "get_latest_release", return_value=mock_data):
result = await version_service.check_for_updates()
assert result["latest"] == "0.2.0"
assert result["release_url"] is None
assert result["release_notes"] is None
assert result["published_at"] is None
assert result["author"] is None
assert result["assets"] == [] # Empty list, not None
def test_clear_cache(version_service, mock_release_data):
"""Test cache clearing."""
# Set up cache
version_service._cache = mock_release_data
version_service._cache_time = datetime.now()
# Clear cache
version_service.clear_cache()
assert version_service._cache is None
assert version_service._cache_time is None
def test_is_newer_version():
"""Test version comparison logic using the utility function."""
from src.server.utils.semantic_version import is_newer_version
# Test various version comparisons
assert is_newer_version("1.0.0", "2.0.0") is True
assert is_newer_version("2.0.0", "1.0.0") is False
assert is_newer_version("1.0.0", "1.0.0") is False
assert is_newer_version("1.0.0", "1.1.0") is True
assert is_newer_version("1.0.0", "1.0.1") is True
assert is_newer_version("1.2.3", "1.2.3") is False