diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index ca8fc0d2..396fe9a8 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -5,6 +5,7 @@ import { inject } from '@adonisjs/core' import transmit from '@adonisjs/transmit/services/main' import { doResumableDownloadWithRetry } from '../utils/downloads.js' import { pickAmdHsaOverride } from '../utils/amd_hsa_override.js' +import { recreatesOnStart } from '../utils/dependency_start.js' import { join } from 'path' import os from 'node:os' import env from '#start/env' @@ -92,7 +93,7 @@ export class DockerService { ): Promise<{ success: boolean; message: string }> { try { const service = await Service.query().where('service_name', serviceName).first() - if (!service || !service.installed) { + if (!service) { return { success: false, message: `Service ${serviceName} not found or not installed`, @@ -101,6 +102,24 @@ export class DockerService { const containers = await this.docker.listContainers({ all: true }) const container = containers.find((c) => c.Names.includes(`/${serviceName}`)) + + if (recreatesOnStart(action, service.is_dependency_service, !!container)) { + logger.info(`[DockerService] ${serviceName} has no container. Recreating it.`) + // Preflight refuses a row still marked installed; the next sync would reset it anyway. + if (service.installed) { + service.installed = false + await service.save() + } + return await this.createContainerPreflight(serviceName) + } + + if (!service.installed) { + return { + success: false, + message: `Service ${serviceName} not found or not installed`, + } + } + if (!container) { return { success: false, diff --git a/admin/app/utils/dependency_start.ts b/admin/app/utils/dependency_start.ts new file mode 100644 index 00000000..6f13d17c --- /dev/null +++ b/admin/app/utils/dependency_start.ts @@ -0,0 +1,18 @@ +/** + * Whether a "start" request should recreate the service's container instead of + * starting it (#1426). + * + * A hidden dependency service (Qdrant) has no card on the Apps page, so the + * Knowledge Base's "Start Qdrant" button is the only control a user has over it. + * When its container is gone there is nothing to start, and the next container sync + * marks the row not installed, so the only way back is to recreate it from the + * stored config. Its data lives on a host bind mount, so recreating it loses nothing. + * Every other service keeps the old answer: the user reinstalls it from the Apps page. + */ +export function recreatesOnStart( + action: 'start' | 'stop' | 'restart', + isDependencyService: boolean, + containerExists: boolean +): boolean { + return action === 'start' && isDependencyService && !containerExists +} diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 6e71abdd..503bfd78 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -55,6 +55,10 @@ const VIEWABLE_EXTENSIONS = new Set([ 'html', ]) +// How long "Start Qdrant" waits for the health check to come back online. Long +// enough for a recreate that has to pull the image again. +const QDRANT_START_TIMEOUT_MS = 5 * 60_000 + function isViewableExtension(filename: string): boolean { const ext = filename.split('.').at(-1)?.toLowerCase() ?? '' return VIEWABLE_EXTENSIONS.has(ext) @@ -228,6 +232,20 @@ export default function KnowledgeBaseModal({ if (!qdrantOffline) setIsStartingQdrant(false) }, [qdrantOffline]) + // Stop waiting on a Qdrant that started and then crashed, or whose recreate failed. + useEffect(() => { + if (!isStartingQdrant) return + const timer = setTimeout(() => { + setIsStartingQdrant(false) + addNotification({ + type: 'error', + message: + 'Qdrant did not come online. Check its logs under Settings → Service Logs & Metrics.', + }) + }, QDRANT_START_TIMEOUT_MS) + return () => clearTimeout(timer) + }, [isStartingQdrant]) + const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({ queryKey: ['storedFiles'], queryFn: () => api.getStoredRAGFiles(), @@ -399,7 +417,12 @@ export default function KnowledgeBaseModal({ const startQdrantMutation = useMutation({ mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'), - onSuccess: () => { + onSuccess: (result) => { + // A refused start comes back as success: false, not as a thrown error. + if (!result?.success) { + addNotification({ type: 'error', message: result?.message || 'Failed to start Qdrant.' }) + return + } setIsStartingQdrant(true) queryClient.invalidateQueries({ queryKey: ['qdrantHealth'] }) }, diff --git a/admin/tests/unit/dependency_start.spec.ts b/admin/tests/unit/dependency_start.spec.ts new file mode 100644 index 00000000..6a289722 --- /dev/null +++ b/admin/tests/unit/dependency_start.spec.ts @@ -0,0 +1,21 @@ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' + +import { recreatesOnStart } from '../../app/utils/dependency_start.js' + +test('starting a dependency service whose container is gone recreates it', () => { + assert.equal(recreatesOnStart('start', true, false), true) +}) + +test('starting a dependency service whose container exists just starts it', () => { + assert.equal(recreatesOnStart('start', true, true), false) +}) + +test('stop and restart never recreate a missing dependency container', () => { + assert.equal(recreatesOnStart('stop', true, false), false) + assert.equal(recreatesOnStart('restart', true, false), false) +}) + +test('starting a user-facing app with no container does not recreate it', () => { + assert.equal(recreatesOnStart('start', false, false), false) +})