feat(content): opt-in automatic updates for installed ZIM & map content

This commit is contained in:
jakeaturner 2026-06-08 17:20:49 +00:00
parent 9de6473f6a
commit 6a2d4c2bf6
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
28 changed files with 2372 additions and 80 deletions

View File

@ -4,6 +4,7 @@ import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { AutoUpdateService } from '#services/auto_update_service'
import { AppAutoUpdateService } from '#services/app_auto_update_service'
import { ContentAutoUpdateService } from '#services/content_auto_update_service'
import { DownloadService } from '#services/download_service'
import { QueueService } from '#services/queue_service'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
@ -174,6 +175,23 @@ export default class SystemController {
}
}
async getContentAutoUpdateStatus({ response }: HttpContext) {
// Mirrors getAppAutoUpdateStatus. Content auto-update needs only the
// DownloadService (for the active-download pre-flight); the catalog and
// collection-update services default-construct inside the service.
const contentAutoUpdateService = new ContentAutoUpdateService(
new DownloadService(QueueService.getInstance())
)
try {
const status = await contentAutoUpdateService.getStatus()
response.send(status)
} catch (error) {
logger.error({ err: error }, '[SystemController] Failed to get content auto-update status')
response.status(500).send({ error: 'Failed to retrieve content auto-update status' })
}
}
async setServiceAutoUpdate({ request, response }: HttpContext) {
const payload = await request.validateUsing(setServiceAutoUpdateValidator)
const service = await Service.query().where('service_name', payload.service_name).first()

View File

@ -0,0 +1,72 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DownloadService } from '#services/download_service'
import { ContentAutoUpdateService } from '#services/content_auto_update_service'
import logger from '@adonisjs/core/services/logger'
/**
* Hourly job that evaluates whether any installed content (ZIM/map) should
* auto-update right now and, if so, dispatches the downloads. All gating (master
* switch, content window, cool-off, per-window data cap, pre-flight, backoff)
* lives in {@link ContentAutoUpdateService}; this job is just the scheduled
* trigger. Runs hourly so it can act anywhere inside a user's window regardless
* of the window's length. Mirrors {@link AppAutoUpdateJob}.
*/
export class ContentAutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'content-auto-update'
}
async handle(_job: Job) {
logger.info('[ContentAutoUpdateJob] Evaluating content auto-updates...')
const contentAutoUpdateService = new ContentAutoUpdateService(
new DownloadService(QueueService.getInstance())
)
const result = await contentAutoUpdateService.attempt()
logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`)
return result
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'hourly-content-auto-update',
{ pattern: '0 * * * *' }, // Top of every hour; attempt() gates on the window
{
name: this.key,
opts: {
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
},
}
)
logger.info('[ContentAutoUpdateJob] Content auto-update evaluation scheduled with cron: 0 * * * *')
}
static async dispatch() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(
this.key,
{},
{
attempts: 1,
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
}
)
logger.info(`[ContentAutoUpdateJob] Dispatched ad-hoc content auto-update evaluation job ${job.id}`)
return job
}
}

View File

@ -6,7 +6,36 @@ import { createHash } from 'crypto'
import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_service'
import { MapService } from '#services/map_service'
import { RagService } from '#services/rag_service'
import { OllamaService } from '#services/ollama_service'
import { EmbedFileJob } from './embed_file_job.js'
import { basename, join, resolve, sep } from 'node:path'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
/** Maps live under `<cwd>/storage/maps/pmtiles`; no shared constant exists. */
const MAP_STORAGE_PATH = '/storage/maps'
/**
* Guard for the outdated-file deletion in {@link RunDownloadJob} `onComplete`:
* returns true only when `oldFilePath` sits under the expected content storage
* root for its type AND its filename carries this resource's id prefix. This
* makes the delete explicit and bounded we only ever remove the replaced
* resource's own previous file, never another file, even if the
* InstalledResource row is stale or malformed.
*/
function isSafeOldContentPath(
oldFilePath: string,
resourceId: string,
filetype: string
): boolean {
const root =
filetype === 'zim'
? join(process.cwd(), ZIM_STORAGE_PATH)
: join(process.cwd(), MAP_STORAGE_PATH)
const resolved = resolve(oldFilePath)
if (!resolved.startsWith(root + sep)) return false
return basename(resolved).startsWith(`${resourceId}_`)
}
export class RunDownloadJob {
static get queue() {
@ -98,6 +127,11 @@ export class RunDownloadJob {
lastKnownProgress = { downloadedBytes: progress.downloadedBytes, totalBytes: progress.totalBytes }
},
async onComplete(url) {
// The previous file recorded for this resource (if any). Hoisted out of
// the metadata block below so the ZIM branch can decide whether this
// download is a content UPDATE (replacing a prior file) vs a fresh
// install, which changes how we reconcile the knowledge base.
let oldFilePath: string | null = null
try {
// Create InstalledResource entry if metadata was provided
if (resourceMetadata) {
@ -111,9 +145,9 @@ export class RunDownloadJob {
.where('resource_id', resourceMetadata.resource_id)
.where('resource_type', filetype as 'zim' | 'map')
.first()
const oldFilePath = oldEntry?.file_path ?? null
oldFilePath = oldEntry?.file_path ?? null
await InstalledResource.updateOrCreate(
const installed = await InstalledResource.updateOrCreate(
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
{
version: resourceMetadata.version,
@ -125,15 +159,45 @@ export class RunDownloadJob {
}
)
// Delete the old file if it differs from the new one
if (oldFilePath && oldFilePath !== filepath) {
// A completed auto-update is the authoritative success signal for the
// per-resource backoff — clear it here (NOT at dispatch time, which
// would reset the counter every window and defeat self-disable). The
// matching terminal-failure increment lives in the worker `failed`
// handler (commands/queue/work.ts). Manual downloads (auto !== true)
// never touch the counter.
if (resourceMetadata.auto === true) {
try {
await deleteFileIfExists(oldFilePath)
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
} catch (deleteError) {
const { recordResourceUpdateSuccess } = await import(
'../utils/content_auto_update_backoff.js'
)
await recordResourceUpdateSuccess(installed)
} catch (error) {
console.error(
`[RunDownloadJob] Error clearing auto-update backoff for ${resourceMetadata.resource_id}:`,
error
)
}
}
// Step 1: delete the OUTDATED file if it differs from the new one.
// Guarded by isSafeOldContentPath so we can ONLY ever delete the
// replaced resource's own previous file — never another resource's
// file, even if the InstalledResource row is stale/malformed.
if (oldFilePath && oldFilePath !== filepath) {
if (isSafeOldContentPath(oldFilePath, resourceMetadata.resource_id, filetype)) {
try {
await deleteFileIfExists(oldFilePath)
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
} catch (deleteError) {
console.warn(
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
deleteError
)
}
} else {
console.warn(
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
deleteError
`[RunDownloadJob] Refusing to delete unexpected old file path for ` +
`${resourceMetadata.resource_id} (${filetype}): ${oldFilePath}`
)
}
}
@ -144,42 +208,76 @@ export class RunDownloadJob {
const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true)
// Only dispatch embedding job if AI Assistant (Ollama) is installed
// Only touch the knowledge base if AI Assistant (Ollama) is installed
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
// Respect the global ingest policy. Under Manual, record the file
// as pending_decision so the KB panel surfaces the per-file Index
// affordance (PR #909) instead of silently auto-embedding behind
// the user's back. Unset is treated as Always to preserve legacy
// behavior — mirrors rag_service.ts:1587-1588.
const { default: KVStore } = await import('#models/kv_store')
const { default: KbIngestState } = await import('#models/kb_ingest_state')
const policyRaw = await KVStore.getValue('rag.defaultIngestPolicy')
const policy: 'Always' | 'Manual' = policyRaw === 'Manual' ? 'Manual' : 'Always'
// A content UPDATE replaces a prior file at a DIFFERENT path
// (version is in the filename). A fresh install has no prior row;
// a same-version re-download keeps the same path. The two cases
// reconcile the KB differently.
const isReplacement = !!oldFilePath && oldFilePath !== filepath
if (policy === 'Manual') {
if (isReplacement) {
// CONTENT UPDATE: mirror the REPLACED file's prior indexed state
// rather than the global Always/Manual policy. reconcileReplaced-
// ContentFile removes the old file's points and re-queues the new
// file IFF the old one was indexed and Qdrant is running; it is a
// no-op otherwise (not installed / old not indexed / Qdrant down).
// The user already chose whether this content is in the KB, so we
// honor that choice in both directions. See the method for the
// full 5-step contract.
try {
// firstOrCreate so a re-download doesn't demote an existing
// indexed/failed row — user keeps prior state and can re-index
// explicitly from the KB panel if they want fresh content.
await KbIngestState.firstOrCreate(
{ file_path: filepath },
{ file_path: filepath, state: 'pending_decision', chunks_embedded: 0 }
const ragService = new RagService(dockerService, new OllamaService())
const outcome = await ragService.reconcileReplacedContentFile({
oldFilePath: oldFilePath!,
newFilePath: filepath,
fileName: url.split('/').pop() || '',
})
console.log(
`[RunDownloadJob] KB reconciliation for replaced ${filepath}: ${outcome}`
)
} catch (error) {
console.error(
`[RunDownloadJob] Error recording pending_decision state for ${filepath}:`,
`[RunDownloadJob] Error reconciling knowledge base for replaced file ${filepath}:`,
error
)
}
} else {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
// FRESH INSTALL (or same-version re-download): respect the global
// ingest policy. Under Manual, record the file as pending_decision
// so the KB panel surfaces the per-file Index affordance (PR #909)
// instead of silently auto-embedding behind the user's back. Unset
// is treated as Always to preserve legacy behavior — mirrors
// rag_service.ts:1587-1588.
const { default: KVStore } = await import('#models/kv_store')
const { default: KbIngestState } = await import('#models/kb_ingest_state')
const policyRaw = await KVStore.getValue('rag.defaultIngestPolicy')
const policy: 'Always' | 'Manual' = policyRaw === 'Manual' ? 'Manual' : 'Always'
if (policy === 'Manual') {
try {
// firstOrCreate so a re-download doesn't demote an existing
// indexed/failed row — user keeps prior state and can re-index
// explicitly from the KB panel if they want fresh content.
await KbIngestState.firstOrCreate(
{ file_path: filepath },
{ file_path: filepath, state: 'pending_decision', chunks_embedded: 0 }
)
} catch (error) {
console.error(
`[RunDownloadJob] Error recording pending_decision state for ${filepath}:`,
error
)
}
} else {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
}
}
}
}

View File

@ -30,4 +30,25 @@ export default class InstalledResource extends BaseModel {
@column.dateTime()
declare installed_at: DateTime
// ── Content auto-update state (global opt-in; gated by `contentAutoUpdate.enabled`) ──
/** Newest catalog version (YYYY-MM) detected, or null when already current. */
@column()
declare available_update_version: string | null
/** Size (bytes) of the available update, captured from the catalog. */
@column()
declare available_update_size_bytes: number | null
/** Cool-off anchor: when the current available update was first detected. */
@column.dateTime()
declare available_update_first_seen_at: DateTime | null
/** Per-resource failure backoff so one flapping download self-disables. */
@column()
declare auto_update_consecutive_failures: number
@column()
declare auto_update_disabled_reason: string | null
}

View File

@ -1,16 +1,15 @@
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import axios from 'axios'
import { DateTime } from 'luxon'
import InstalledResource from '#models/installed_resource'
import { RunDownloadJob } from '../jobs/run_download_job.js'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
import { join } from 'path'
import type {
ResourceUpdateCheckRequest,
ResourceUpdateInfo,
ContentUpdateCheckResult,
} from '../../types/collections.js'
import { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'
import { KiwixCatalogService, reconcileResourceUpdateState } from './kiwix_catalog_service.js'
const MAP_STORAGE_PATH = '/storage/maps'
@ -18,16 +17,13 @@ const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'applicati
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
export class CollectionUpdateService {
/**
* Check every installed resource against the upstream catalogs locally (Kiwix
* OPDS for ZIMs, GitHub for maps) no longer routed through the external
* project-nomad-api. Side-effect: persists each resource's available-update
* state (version + cool-off anchor) so the auto-updater can act on it later.
*/
async checkForUpdates(): Promise<ContentUpdateCheckResult> {
const nomadAPIURL = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
if (!nomadAPIURL) {
return {
updates: [],
checked_at: new Date().toISOString(),
error: 'Nomad API is not configured. Set the NOMAD_API_URL environment variable.',
}
}
const installed = await InstalledResource.all()
if (installed.length === 0) {
return {
@ -36,53 +32,53 @@ export class CollectionUpdateService {
}
}
const requestBody: ResourceUpdateCheckRequest = {
resources: installed.map((r) => ({
resource_id: r.resource_id,
resource_type: r.resource_type,
installed_version: r.version,
})),
}
try {
const response = await axios.post<ResourceUpdateInfo[]>(`${nomadAPIURL}/api/v1/resources/check-updates`, requestBody, {
timeout: 15000,
})
logger.info(
`[CollectionUpdateService] Update check complete: ${response.data.length} update(s) available`
const catalog = new KiwixCatalogService()
const latestByKey = await catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
)
const updates = await this.enrichWithSizes(response.data)
const now = DateTime.now()
const updates: ResourceUpdateInfo[] = []
for (const resource of installed) {
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
await reconcileResourceUpdateState(resource, latest, now)
if (latest && latest.version > resource.version) {
updates.push({
resource_id: resource.resource_id,
resource_type: resource.resource_type,
installed_version: resource.version,
latest_version: latest.version,
download_url: latest.download_url,
size_bytes: latest.size_bytes || undefined,
})
}
}
logger.info(
`[CollectionUpdateService] Local update check complete: ${updates.length} update(s) available`
)
const enriched = await this.enrichWithSizes(updates)
return {
updates,
updates: enriched,
checked_at: new Date().toISOString(),
}
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
logger.error(
`[CollectionUpdateService] Nomad API returned ${error.response.status}: ${JSON.stringify(error.response.data)}`
)
return {
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to check for content updates. The update service may be temporarily unavailable.',
}
}
const message =
error instanceof Error ? error.message : 'Unknown error contacting Nomad API'
const message = error instanceof Error ? error.message : 'Unknown error during update check'
logger.error(`[CollectionUpdateService] Failed to check for updates: ${message}`)
return {
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to contact the update service. Please try again later.',
error: 'Failed to check for content updates. Please try again later.',
}
}
}
async applyUpdate(
update: ResourceUpdateInfo
update: ResourceUpdateInfo,
options?: { auto?: boolean }
): Promise<{ success: boolean; jobId?: string; error?: string }> {
// Check if a download is already in progress for this URL
const existingJob = await RunDownloadJob.getByUrl(update.download_url)
@ -113,6 +109,7 @@ export class CollectionUpdateService {
resource_id: update.resource_id,
version: update.latest_version,
collection_ref: null,
auto: options?.auto ?? false,
},
})

View File

@ -0,0 +1,550 @@
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import KVStore from '#models/kv_store'
import InstalledResource from '#models/installed_resource'
import { DownloadService } from '#services/download_service'
import { CollectionUpdateService } from '#services/collection_update_service'
import {
KiwixCatalogService,
reconcileResourceUpdateState,
type CatalogResult,
} from '#services/kiwix_catalog_service'
import { isWithinWindow, parseWindowMinutes } from '../utils/update_window.js'
import { recordResourceUpdateFailure } from '../utils/content_auto_update_backoff.js'
import type { Blocker, PreflightResult } from '../utils/image_disk_preflight.js'
/**
* Content auto-update is opt-in via a single global master switch and runs on
* its OWN window + per-window data cap (deliberately separate from the core/app
* `autoUpdate.*` window, since ZIM downloads are multi-GB and bandwidth
* sensitive). Defaults err toward an overnight window with no cap; the UI
* strongly recommends setting a cap.
*/
const DEFAULT_WINDOW_START = '02:00'
const DEFAULT_WINDOW_END = '05:00'
const DEFAULT_COOLOFF_HOURS = 72
/** Whole-feature failures (e.g. catalog unreachable) before it self-disables. */
const MAX_FEATURE_FAILURES = 3
export interface ContentAutoUpdateConfig {
/** Global master switch (`contentAutoUpdate.enabled`). */
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
/** Max NEW bytes initiated per window instance. 0 = unlimited. */
maxBytesPerWindow: number
}
/** Per-resource eligibility verdict (drives both selection and the status UI). */
export interface ContentEligibility {
eligible: boolean
reason: string
cooloffRemainingHours: number | null
}
/** An eligible resource paired with the catalog facts needed to download it. */
export interface ContentCandidate {
resource: InstalledResource
version: string
download_url: string
size_bytes: number
installed_at: DateTime
}
/** Outcome of the cap-bounded greedy selection. */
export interface ContentSelection {
selected: ContentCandidate[]
/** Single files larger than the whole cap — never auto-started (manual only). */
skippedOversize: ContentCandidate[]
/** Fit the cap but not this window's remaining budget — retried next window. */
deferred: ContentCandidate[]
}
export interface ContentAutoUpdateResourceStatus {
resource_id: string
resource_type: 'zim' | 'map'
current_version: string
available_update_version: string | null
size_bytes: number | null
eligible: boolean
reason: string
cooloff_remaining_hours: number | null
exceeds_cap: boolean
consecutive_failures: number
auto_disabled_reason: string | null
}
export interface ContentAutoUpdateStatus extends ContentAutoUpdateConfig {
withinWindow: boolean
windowBytesUsed: number
lastAttemptAt: string | null
lastResult: string | null
lastError: string | null
autoDisabledReason: string | null
resources: ContentAutoUpdateResourceStatus[]
}
/**
* Decision + safety layer for automatic content (ZIM/map) updates. This is the
* content-side counterpart to {@link AppAutoUpdateService}: it decides *whether*
* each installed resource with an available update should be downloaded now
* (master switch on + in the content window + past cool-off + within the data
* cap) and then drives the existing manual download path
* ({@link CollectionUpdateService.applyUpdate} {@link RunDownloadJob}).
*
* It never installs synchronously it dispatches resumable download jobs and
* lets the existing job-completion path advance the installed version and
* rebuild the Kiwix library.
*/
export class ContentAutoUpdateService {
constructor(
private downloadService: DownloadService,
private catalog: KiwixCatalogService = new KiwixCatalogService(),
private collectionUpdateService: CollectionUpdateService = new CollectionUpdateService()
) {}
/** Read the master switch plus the content-specific window/cool-off/cap. */
async getConfig(): Promise<ContentAutoUpdateConfig> {
const [enabled, windowStart, windowEnd, cooloffHours, maxBytes] = await Promise.all([
KVStore.getValue('contentAutoUpdate.enabled'),
KVStore.getValue('contentAutoUpdate.windowStart'),
KVStore.getValue('contentAutoUpdate.windowEnd'),
KVStore.getValue('contentAutoUpdate.cooloffHours'),
KVStore.getValue('contentAutoUpdate.maxBytesPerWindow'),
])
const parsedCooloff = Number(cooloffHours)
const parsedCap = Number(maxBytes)
return {
enabled: enabled ?? false,
windowStart: windowStart || DEFAULT_WINDOW_START,
windowEnd: windowEnd || DEFAULT_WINDOW_END,
// `Number(null) === 0`, so an unset value must fall through to the default
// rather than silently resolving to a zero cool-off. An explicit 0 is honored.
cooloffHours:
cooloffHours !== null && Number.isFinite(parsedCooloff) && parsedCooloff >= 0
? parsedCooloff
: DEFAULT_COOLOFF_HOURS,
maxBytesPerWindow:
maxBytes !== null && Number.isFinite(parsedCap) && parsedCap >= 0 ? parsedCap : 0,
}
}
/**
* Pure per-resource eligibility verdict. A resource is eligible when it has a
* detected newer version, is not self-disabled, and is past its cool-off
* (measured from first-detected). Version comparison is a lexicographic
* compare of the YYYY-MM stamps, which sorts chronologically.
*/
resourceEligibility(
resource: InstalledResource,
cooloffHours: number,
now: DateTime
): ContentEligibility {
if (!resource.available_update_version) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (resource.auto_update_disabled_reason) {
return {
eligible: false,
reason: 'Auto-update disabled after repeated failures',
cooloffRemainingHours: null,
}
}
if (!(resource.available_update_version > resource.version)) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (!resource.available_update_first_seen_at) {
return { eligible: false, reason: 'Cool-off pending', cooloffRemainingHours: cooloffHours }
}
const ageHours = now.diff(resource.available_update_first_seen_at, 'hours').hours
const remaining = cooloffHours - ageHours
if (remaining > 0) {
const rounded = Math.ceil(remaining)
return {
eligible: false,
reason: `In cool-off (${rounded}h remaining)`,
cooloffRemainingHours: rounded,
}
}
return {
eligible: true,
reason: `Eligible → ${resource.available_update_version}`,
cooloffRemainingHours: 0,
}
}
/**
* Pure cap-bounded greedy selection. Oldest-installed first (stale content is
* prioritized), tie-broken smallest-first for predictability.
*
* - size unknown (0) deferred (can't budget safely)
* - size > the WHOLE cap skippedOversize (never auto-started; manual only)
* - size remaining budget selected
* - otherwise deferred (fits the cap, not this window)
*/
selectUnderCap(
candidates: ContentCandidate[],
capBytes: number,
usedBytes: number
): ContentSelection {
const cap = capBytes > 0 ? capBytes : Number.POSITIVE_INFINITY
let remaining = Math.max(0, cap - usedBytes)
const selected: ContentCandidate[] = []
const skippedOversize: ContentCandidate[] = []
const deferred: ContentCandidate[] = []
const ordered = [...candidates].sort((a, b) => {
const at = a.installed_at?.toMillis?.() ?? 0
const bt = b.installed_at?.toMillis?.() ?? 0
if (at !== bt) return at - bt
return a.size_bytes - b.size_bytes
})
for (const candidate of ordered) {
if (candidate.size_bytes <= 0) {
deferred.push(candidate)
} else if (candidate.size_bytes > cap) {
skippedOversize.push(candidate)
} else if (candidate.size_bytes <= remaining) {
selected.push(candidate)
remaining -= candidate.size_bytes
} else {
deferred.push(candidate)
}
}
return { selected, skippedOversize, deferred }
}
/**
* Run-wide pre-flight: never auto-update content while ANY download is already
* running. Because content downloads are multi-GB and resumable, an in-flight
* download from a prior window naturally blocks new starts here exactly the
* "let in-flight finish, don't start new" behavior we want. Transient `skip`.
*/
async runGlobalPreflight(): Promise<PreflightResult> {
const blockers: Blocker[] = []
try {
const downloads = await this.downloadService.listDownloadJobs()
const active = downloads.filter(
(d) => !!d.status && ['waiting', 'active', 'delayed'].includes(d.status)
)
if (active.length > 0) {
blockers.push({ reason: `${active.length} download(s) in progress`, severity: 'skip' })
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`[ContentAutoUpdateService] Could not check active downloads: ${message}`)
}
return { ok: blockers.length === 0, blockers }
}
/**
* Entry point invoked by ContentAutoUpdateJob. Gates on the master switch +
* window, runs the local catalog check, then downloads as many eligible
* resources as fit under the per-window data cap.
*/
async attempt(): Promise<{ started: number; reason: string }> {
const config = await this.getConfig()
const now = DateTime.now()
if (!config.enabled) {
return { started: 0, reason: 'Content auto-update is disabled' }
}
if (!isWithinWindow(config.windowStart, config.windowEnd, now)) {
const reason = `Outside update window (${config.windowStart}-${config.windowEnd})`
await this.recordRun(reason)
return { started: 0, reason }
}
try {
// Reset the per-window budget once per window instance (the cron fires
// hourly but a window can span several hours).
await this.maybeResetWindowBudget(config, now)
// Local catalog check + persist available-update state for every resource.
const installed = await InstalledResource.all()
const latestByKey = await this.catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
)
for (const resource of installed) {
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
await reconcileResourceUpdateState(resource, latest, now)
}
const eligible = installed.filter(
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
)
if (eligible.length === 0) {
await this.recordFeatureSuccess()
const reason = 'No eligible content updates'
await this.recordRun(reason)
return { started: 0, reason }
}
const global = await this.runGlobalPreflight()
if (!global.ok) {
await this.recordFeatureSuccess()
const reason = `Pre-flight blocked: ${global.blockers.map((b) => b.reason).join('; ')}`
await this.recordRun(reason)
return { started: 0, reason }
}
const candidates: ContentCandidate[] = eligible.map((r) => {
const latest = latestByKey.get(`${r.resource_type}:${r.resource_id}`) as CatalogResult
return {
resource: r,
version: latest.version,
download_url: latest.download_url,
size_bytes: r.available_update_size_bytes ?? latest.size_bytes ?? 0,
installed_at: r.installed_at,
}
})
const usedBytes = await this.getWindowBytesUsed()
const { selected, skippedOversize, deferred } = this.selectUnderCap(
candidates,
config.maxBytesPerWindow,
usedBytes
)
let started = 0
let failed = 0
let initiatedBytes = 0
for (const candidate of selected) {
const result = await this.collectionUpdateService.applyUpdate(
{
resource_id: candidate.resource.resource_id,
resource_type: candidate.resource.resource_type,
installed_version: candidate.resource.version,
latest_version: candidate.version,
download_url: candidate.download_url,
size_bytes: candidate.size_bytes || undefined,
},
{ auto: true }
)
if (result.success) {
// Success is NOT recorded here: applyUpdate only enqueues a resumable
// download. The per-resource backoff is cleared once the download
// actually completes (RunDownloadJob.onComplete) and incremented when it
// fails terminally (the worker `failed` handler). Recording success on
// dispatch would reset the counter every window and defeat self-disable.
initiatedBytes += candidate.size_bytes
started++
logger.info(
`[ContentAutoUpdateService] Started ${candidate.resource.resource_id}${candidate.version}`
)
} else {
// A failure to even enqueue is a genuine auto-update failure; no job runs,
// so no terminal `failed` event will follow — count it here.
await recordResourceUpdateFailure(candidate.resource, result.error ?? 'dispatch failed')
failed++
}
}
if (initiatedBytes > 0) {
await this.addWindowBytesUsed(initiatedBytes)
}
const parts = [`${started} started`]
if (failed) parts.push(`${failed} failed`)
if (skippedOversize.length) parts.push(`${skippedOversize.length} skipped (exceeds cap)`)
if (deferred.length) parts.push(`${deferred.length} deferred (over budget)`)
const reason = parts.join(', ')
await this.recordFeatureSuccess()
await this.recordRun(reason)
logger.info(`[ContentAutoUpdateService] Run complete: ${reason}`)
return { started, reason }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await this.recordFeatureFailure(message)
await this.recordRun(`Failed: ${message}`)
logger.error(`[ContentAutoUpdateService] Run failed: ${message}`)
return { started: 0, reason: `Failed: ${message}` }
}
}
/**
* Evaluate what the next run *would* do, without hitting the network,
* persisting state, or dispatching anything. Operates on the available-update
* state already persisted by the last check (manual or auto), so run a "Check
* for Content Updates" first if you want fresh catalog data. Used by the
* `content-auto-update:dry-run` command.
*/
async dryRun(
overrides: {
now?: DateTime
forceEnabled?: boolean
cooloffHours?: number
windowStart?: string
windowEnd?: string
maxBytesPerWindow?: number
windowBytesUsed?: number
} = {}
): Promise<{
enabled: boolean
withinWindow: boolean
config: ContentAutoUpdateConfig
eligibleCount: number
selection: ContentSelection
}> {
const base = await this.getConfig()
const config: ContentAutoUpdateConfig = {
enabled: overrides.forceEnabled ? true : base.enabled,
windowStart: overrides.windowStart ?? base.windowStart,
windowEnd: overrides.windowEnd ?? base.windowEnd,
cooloffHours: overrides.cooloffHours ?? base.cooloffHours,
maxBytesPerWindow: overrides.maxBytesPerWindow ?? base.maxBytesPerWindow,
}
const now = overrides.now ?? DateTime.now()
const withinWindow = isWithinWindow(config.windowStart, config.windowEnd, now)
const pending = await InstalledResource.query().whereNotNull('available_update_version')
const eligible = pending.filter(
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
)
const candidates: ContentCandidate[] = eligible.map((r) => ({
resource: r,
version: r.available_update_version!,
download_url: '(dry-run)',
size_bytes: r.available_update_size_bytes ?? 0,
installed_at: r.installed_at,
}))
const usedBytes = overrides.windowBytesUsed ?? (await this.getWindowBytesUsed())
const selection = this.selectUnderCap(candidates, config.maxBytesPerWindow, usedBytes)
return {
enabled: config.enabled,
withinWindow,
config,
eligibleCount: eligible.length,
selection,
}
}
// ── Per-window budget ─────────────────────────────────────────────────────────
/** Most-recent window-open boundary as an absolute timestamp (handles wrap). */
windowStartBoundary(windowStart: string, now: DateTime): DateTime {
const minutes = parseWindowMinutes(windowStart) ?? 0
const todayStart = now.startOf('day').plus({ minutes })
return now >= todayStart ? todayStart : todayStart.minus({ days: 1 })
}
/** Reset the window budget exactly once per entry into the window. */
private async maybeResetWindowBudget(
config: ContentAutoUpdateConfig,
now: DateTime
): Promise<void> {
const boundary = this.windowStartBoundary(config.windowStart, now)
const resetAtRaw = await KVStore.getValue('contentAutoUpdate.windowResetAt')
const resetAt = resetAtRaw ? DateTime.fromISO(resetAtRaw) : null
if (!resetAt || !resetAt.isValid || resetAt < boundary) {
await KVStore.setValue('contentAutoUpdate.windowBytesUsed', '0')
await KVStore.setValue('contentAutoUpdate.windowResetAt', now.toISO()!)
}
}
private async getWindowBytesUsed(): Promise<number> {
const raw = await KVStore.getValue('contentAutoUpdate.windowBytesUsed')
const num = Number(raw)
return Number.isFinite(num) && num > 0 ? num : 0
}
private async addWindowBytesUsed(bytes: number): Promise<void> {
const used = await this.getWindowBytesUsed()
await KVStore.setValue('contentAutoUpdate.windowBytesUsed', String(used + bytes))
}
// ── Backoff + run recording ───────────────────────────────────────────────────
// Per-resource backoff lives in ../utils/content_auto_update_backoff.ts so the
// job-completion path and the worker `failed` handler can share it without an
// import cycle. The feature-level backoff below stays here.
/** Clear the feature-level backoff after a clean run. */
private async recordFeatureSuccess(): Promise<void> {
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', '0')
await KVStore.clearValue('contentAutoUpdate.lastError')
}
/** Record a whole-feature failure and self-disable the feature at the threshold. */
private async recordFeatureFailure(reason: string): Promise<void> {
const raw = await KVStore.getValue('contentAutoUpdate.consecutiveFailures')
const failures = (Number(raw) || 0) + 1
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', String(failures))
await KVStore.setValue('contentAutoUpdate.lastError', reason)
if (failures >= MAX_FEATURE_FAILURES) {
await KVStore.setValue('contentAutoUpdate.enabled', false)
await KVStore.setValue(
'contentAutoUpdate.autoDisabledReason',
`Content auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
)
logger.error(
`[ContentAutoUpdateService] Feature auto-disabled after ${failures} consecutive failures`
)
}
}
private async recordRun(reason: string): Promise<void> {
await KVStore.setValue('contentAutoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('contentAutoUpdate.lastResult', reason)
}
// ── Status snapshot ───────────────────────────────────────────────────────────
/** Full state snapshot for the settings UI (resources with pending updates). */
async getStatus(): Promise<ContentAutoUpdateStatus> {
const config = await this.getConfig()
const now = DateTime.now()
const pending = await InstalledResource.query().whereNotNull('available_update_version')
const resources: ContentAutoUpdateResourceStatus[] = pending.map((resource) => {
const verdict = this.resourceEligibility(resource, config.cooloffHours, now)
const size = resource.available_update_size_bytes ?? null
const exceedsCap =
config.maxBytesPerWindow > 0 && size !== null && size > config.maxBytesPerWindow
return {
resource_id: resource.resource_id,
resource_type: resource.resource_type,
current_version: resource.version,
available_update_version: resource.available_update_version,
size_bytes: size,
eligible: verdict.eligible && !exceedsCap,
reason: exceedsCap ? 'Exceeds data cap — update manually' : verdict.reason,
cooloff_remaining_hours: verdict.cooloffRemainingHours,
exceeds_cap: exceedsCap,
consecutive_failures: resource.auto_update_consecutive_failures || 0,
auto_disabled_reason: resource.auto_update_disabled_reason,
}
})
const [lastAttemptAt, lastResult, lastError, autoDisabledReason, windowBytesUsed] =
await Promise.all([
KVStore.getValue('contentAutoUpdate.lastAttemptAt'),
KVStore.getValue('contentAutoUpdate.lastResult'),
KVStore.getValue('contentAutoUpdate.lastError'),
KVStore.getValue('contentAutoUpdate.autoDisabledReason'),
this.getWindowBytesUsed(),
])
return {
...config,
withinWindow: isWithinWindow(config.windowStart, config.windowEnd, now),
windowBytesUsed,
lastAttemptAt: lastAttemptAt || null,
lastResult: lastResult || null,
lastError: lastError || null,
autoDisabledReason: autoDisabledReason || null,
resources,
}
}
}

View File

@ -0,0 +1,338 @@
import axios from 'axios'
import { XMLParser } from 'fast-xml-parser'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import InstalledResource from '#models/installed_resource'
import { isRawListRemoteZimFilesResponse } from '../../util/zim.js'
/**
* Local, in-process freshness check for installed content (Kiwix ZIM files +
* PMTiles maps). This replaces the former dependency on the external
* project-nomad-api `/api/v1/resources/check-updates` endpoint every NOMAD
* instance now queries the upstream catalogs directly.
*
* Downloads have always gone straight to the Kiwix/GitHub mirrors regardless of
* who performed the check, so moving the check in-process only shifts the
* lightweight *catalog* lookup. To stay mirror-respectful the auto-updater gates
* these calls behind the update window and bounds their concurrency; sizes come
* from the catalog metadata so we avoid per-file HEAD requests.
*
* Robustness over the old API: ZIM lookups use the OPDS exact `name=` filter
* (no lossy keyword stripping) and every returned link is still validated
* against the authoritative `^<id>_YYYY-MM\.zim$` filename regex, so a substring
* match in the catalog can never resolve to the wrong book. Parsing is fully
* defensive a malformed entry is skipped, never thrown.
*/
const KIWIX_CATALOG_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'
const GITHUB_PMTILES_URL =
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad-maps/contents/pmtiles'
const CATALOG_TIMEOUT_MS = 15000
/** Bounded paginated fallback scan when the exact `name=` lookup comes up empty. */
const KIWIX_PAGE_SIZE = 60
const MAX_KIWIX_FETCHES = 5
/** Concurrent ZIM catalog lookups — keep small to avoid hammering the mirror. */
const ZIM_CHECK_CONCURRENCY = 4
/** The newest available version of a single resource (a YYYY-MM date stamp). */
export interface CatalogResult {
version: string
download_url: string
size_bytes: number
}
interface CatalogZimEntry {
name: string | null
download_url: string
file_name: string
size_bytes: number
}
interface GithubContentEntry {
name: string
download_url: string | null
size: number
}
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export class KiwixCatalogService {
private parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
textNodeName: '#text',
})
/**
* Resolve the newest available version for a batch of installed resources.
* Returns a map keyed by `"<resource_type>:<resource_id>"`; resources with no
* available newer version (or a failed lookup) are simply absent.
*
* ZIMs are checked one OPDS request each (bounded concurrency). All maps are
* resolved from a single GitHub directory listing.
*/
async getLatestForResources(
resources: Array<{ resource_id: string; resource_type: 'zim' | 'map' }>
): Promise<Map<string, CatalogResult>> {
const result = new Map<string, CatalogResult>()
const zims = resources.filter((r) => r.resource_type === 'zim')
const maps = resources.filter((r) => r.resource_type === 'map')
await this.forEachWithConcurrency(zims, ZIM_CHECK_CONCURRENCY, async (r) => {
try {
const latest = await this.getLatestZim(r.resource_id)
if (latest) result.set(`zim:${r.resource_id}`, latest)
} catch (error) {
logger.warn(
`[KiwixCatalogService] ZIM check failed for ${r.resource_id}: ${error instanceof Error ? error.message : error}`
)
}
})
if (maps.length > 0) {
try {
const listing = await this.fetchMapListing()
for (const r of maps) {
const latest = this.pickNewestMap(listing, r.resource_id)
if (latest) result.set(`map:${r.resource_id}`, latest)
}
} catch (error) {
logger.warn(
`[KiwixCatalogService] Map listing fetch failed: ${error instanceof Error ? error.message : error}`
)
}
}
return result
}
/** Newest catalog version of a single ZIM book, or null if none/older. */
async getLatestZim(resourceId: string): Promise<CatalogResult | null> {
const pattern = new RegExp(`^${escapeRegex(resourceId)}_(\\d{4}-\\d{2})\\.zim$`)
// 1. Exact-name lookup (the robust path).
const named = await this.fetchZimEntries({ name: resourceId, count: 50, start: 0 })
const exact = this.pickNewestZim(named, pattern)
if (exact) return exact
// 2. Fallback: bounded keyword scan in case the catalog ignored `name=` or
// indexes the book under a slightly different name.
return this.scanZimByQuery(resourceId, pattern)
}
/** Newest catalog version of a single PMTiles map, or null if none/older. */
async getLatestMap(resourceId: string): Promise<CatalogResult | null> {
const listing = await this.fetchMapListing()
return this.pickNewestMap(listing, resourceId)
}
// ── ZIM internals ───────────────────────────────────────────────────────────
private pickNewestZim(entries: CatalogZimEntry[], pattern: RegExp): CatalogResult | null {
let latest: CatalogResult | null = null
for (const entry of entries) {
const match = entry.file_name.match(pattern)
if (!match) continue
const version = match[1]
if (!latest || version > latest.version) {
latest = { version, download_url: entry.download_url, size_bytes: entry.size_bytes }
}
}
return latest
}
private async scanZimByQuery(
resourceId: string,
pattern: RegExp
): Promise<CatalogResult | null> {
let start = 0
let total = 0
let latest: CatalogResult | null = null
for (let i = 0; i < MAX_KIWIX_FETCHES; i++) {
const { entries, totalResults } = await this.fetchZimEntriesPage({
q: resourceId,
count: KIWIX_PAGE_SIZE,
start,
})
total = totalResults
if (entries.length === 0) break
start += entries.length
const candidate = this.pickNewestZim(entries, pattern)
if (candidate && (!latest || candidate.version > latest.version)) {
latest = candidate
}
if (start >= total) break
}
return latest
}
private async fetchZimEntries(params: {
name?: string
q?: string
count: number
start: number
}): Promise<CatalogZimEntry[]> {
const { entries } = await this.fetchZimEntriesPage(params)
return entries
}
private async fetchZimEntriesPage(params: {
name?: string
q?: string
count: number
start: number
}): Promise<{ entries: CatalogZimEntry[]; totalResults: number }> {
const res = await axios.get(KIWIX_CATALOG_URL, {
params: {
start: params.start,
count: params.count,
lang: 'eng',
...(params.name ? { name: params.name } : {}),
...(params.q ? { q: params.q } : {}),
},
responseType: 'text',
timeout: CATALOG_TIMEOUT_MS,
})
return this.parseZimEntries(res.data)
}
private parseZimEntries(xml: string): { entries: CatalogZimEntry[]; totalResults: number } {
let parsed: any
try {
parsed = this.parser.parse(xml)
} catch {
return { entries: [], totalResults: 0 }
}
if (!isRawListRemoteZimFilesResponse(parsed)) {
return { entries: [], totalResults: 0 }
}
const feed = parsed.feed
const totalResults = Number(feed?.totalResults)
const rawEntries = feed?.entry
? Array.isArray(feed.entry)
? feed.entry
: [feed.entry]
: []
const entries: CatalogZimEntry[] = []
for (const raw of rawEntries) {
if (!raw || typeof raw !== 'object') continue
const links = Array.isArray(raw.link) ? raw.link : raw.link ? [raw.link] : []
const downloadLink = links.find(
(link: any) =>
link &&
typeof link === 'object' &&
link.type === 'application/x-zim' &&
typeof link.href === 'string'
)
if (!downloadLink) continue
// The OPDS href ends with `.meta4`; strip it to get the real .zim URL.
const href: string = downloadLink.href
const download_url = href.endsWith('.meta4') ? href.slice(0, -'.meta4'.length) : href
const file_name = download_url.split('/').pop() || ''
if (!file_name) continue
const size_bytes = Number.parseInt(downloadLink.length, 10) || 0
entries.push({
name: typeof raw.name === 'string' ? raw.name : null,
download_url,
file_name,
size_bytes,
})
}
return { entries, totalResults: Number.isFinite(totalResults) ? totalResults : 0 }
}
// ── Map internals ────────────────────────────────────────────────────────────
private async fetchMapListing(): Promise<GithubContentEntry[]> {
const res = await axios.get(GITHUB_PMTILES_URL, {
headers: { Accept: 'application/vnd.github+json' },
timeout: CATALOG_TIMEOUT_MS,
})
return Array.isArray(res.data) ? res.data : []
}
private pickNewestMap(listing: GithubContentEntry[], resourceId: string): CatalogResult | null {
const pattern = new RegExp(`^${escapeRegex(resourceId)}_(\\d{4}-\\d{2})\\.pmtiles$`)
let latest: CatalogResult | null = null
for (const file of listing) {
if (!file || typeof file.name !== 'string' || !file.download_url) continue
const match = file.name.match(pattern)
if (!match) continue
const version = match[1]
if (!latest || version > latest.version) {
latest = {
version,
download_url: file.download_url,
size_bytes: typeof file.size === 'number' ? file.size : 0,
}
}
}
return latest
}
// ── Shared ───────────────────────────────────────────────────────────────────
private async forEachWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<void>
): Promise<void> {
let cursor = 0
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++
await worker(items[index])
}
})
await Promise.all(runners)
}
}
/**
* Persist the latest-known available-update state onto an installed resource.
* Shared by the manual check and the auto-updater so both keep the cool-off
* anchor consistent.
*
* The first-seen anchor is reset **only** when the available version string
* actually changes, so a manual "Check for updates" never resets the auto
* cool-off clock. State is cleared entirely once the resource is current (the
* update got installed, or the upstream release was withdrawn).
*/
export async function reconcileResourceUpdateState(
resource: InstalledResource,
latest: CatalogResult | null,
now: DateTime
): Promise<void> {
const hasUpdate = latest !== null && latest.version > resource.version
if (hasUpdate) {
if (resource.available_update_version !== latest!.version) {
resource.available_update_version = latest!.version
resource.available_update_first_seen_at = now
}
// Keep the cached size fresh even when the version is unchanged (the catalog
// may report a size it lacked on a previous check).
const size = latest!.size_bytes || null
if (resource.available_update_size_bytes !== size) {
resource.available_update_size_bytes = size
}
} else if (resource.available_update_version !== null) {
resource.available_update_version = null
resource.available_update_size_bytes = null
resource.available_update_first_seen_at = null
}
if (resource.$isDirty) {
await resource.save()
}
}

View File

@ -18,6 +18,7 @@ import { join, resolve, sep } from 'node:path'
import KVStore from '#models/kv_store'
import KbIngestState from '#models/kb_ingest_state'
import { decideScanAction, type IngestPolicy } from '../utils/kb_ingest_decision.js'
import { decideContentReindex, type ReindexOutcome } from '../utils/content_reindex_decision.js'
import KbRatioRegistry from '#models/kb_ratio_registry'
import { decideWarnings } from '../utils/kb_warning_decision.js'
import type { FileWarning, FileWarningsResult, StoredFileInfo } from '../../types/rag.js'
@ -1309,6 +1310,98 @@ export class RagService {
}
}
/**
* Reconcile the knowledge base after a curated content file (a ZIM) is
* replaced on disk by a newer downloaded version. Called from
* `RunDownloadJob.onComplete` once the new file is written and the old file
* has been deleted from disk.
*
* The decision (see `decideContentReindex` for the exhaustive, tested
* contract) mirrors the REPLACED file's prior indexed state instead of the
* global `rag.defaultIngestPolicy`. On a content update the user has already
* chosen whether this content belongs in the AI knowledge base, so we honor
* that choice in both directions:
*
* 1. (caller already deleted the outdated file from disk)
* 2. Qdrant not installed no-op (no knowledge base exists)
* 3. Old file WAS indexed + Qdrant
* running delete ONLY the old file's points
* (filter `source == oldFilePath`), drop
* its ingest-state row, and queue the new
* file for embedding BYPASSING the
* Always/Manual policy on purpose.
* 4. Old file NOT indexed no-op (respect the prior un-indexed /
* browse-only choice; do NOT auto-embed
* even under an Always policy)
* 5. Old indexed but Qdrant NOT
* currently running no-op. We can't remove the stale points,
* and a queued embed job could be cleared
* before Qdrant returns. Acting half-way is
* wasteful, so we defer entirely. Accepted
* tradeoff: the old file's points linger in
* Qdrant until a future re-index; they are
* NOT auto-reaped here.
*
* Point deletion is exact: ZIM chunks are stored with `source` equal to the
* full file path (see embedAndStoreText callers), so filtering on the old path
* can only ever remove the replaced file's own points.
*
* Returns the decision outcome for logging/tests; never throws on a Qdrant
* hiccup mid-reindex (logged and surfaced as `qdrant_not_running` semantics by
* the caller's try/catch).
*/
public async reconcileReplacedContentFile(params: {
oldFilePath: string
newFilePath: string
fileName: string
}): Promise<ReindexOutcome> {
const { oldFilePath, newFilePath, fileName } = params
const isReplacement = !!oldFilePath && oldFilePath !== newFilePath
// Step 2: is the knowledge base even installed? Short-circuits before any
// KB-state lookup, per the spec ordering.
const qdrantInstalled = isReplacement
? !!(await this.dockerService.getServiceURL(SERVICE_NAMES.QDRANT))
: false
// Steps 3/4: was the OUTDATED file actually indexed? The state row is the
// authoritative signal (RFC #883) — chunk presence alone can't distinguish
// a fully-indexed file from a stalled ingestion.
let oldFileWasIndexed = false
if (isReplacement && qdrantInstalled) {
const oldState = await KbIngestState.query().where('file_path', oldFilePath).first()
oldFileWasIndexed = oldState?.state === 'indexed'
}
// Step 5: only check liveness once we know we'd otherwise act. The health
// check is a real network round-trip, so we avoid it on the common no-op
// paths above.
let qdrantRunning = false
if (isReplacement && qdrantInstalled && oldFileWasIndexed) {
qdrantRunning = (await this.checkQdrantHealth()).online
}
const outcome = decideContentReindex({
isReplacement,
qdrantInstalled,
oldFileWasIndexed,
qdrantRunning,
})
if (outcome === 'reindex') {
// Order matters: remove the stale points and state row BEFORE queueing the
// new embed so a fresh index can't be conflated with the old one. Each step
// targets only `oldFilePath` / `newFilePath` — never another resource.
await this._deletePointsBySource(oldFilePath)
await KbIngestState.remove(oldFilePath)
const { EmbedFileJob } = await import('#jobs/embed_file_job')
await EmbedFileJob.dispatch({ fileName, filePath: newFilePath })
}
return outcome
}
public async discoverNomadDocs(force?: boolean): Promise<{ success: boolean; message: string }> {
try {
const README_PATH = join(process.cwd(), 'README.md')

View File

@ -1,4 +1,5 @@
import Service from '#models/service'
import InstalledResource from '#models/installed_resource'
import { inject } from '@adonisjs/core'
import { DockerService } from '#services/docker_service'
import { ServiceSlim } from '../../types/services.js'
@ -869,6 +870,17 @@ export class SystemService {
auto_update_disabled_reason: null,
})
}
// Re-enabling content auto-update clears the feature-level backoff and every
// resource's per-resource backoff so previously self-disabled content gets a
// fresh start.
if (key === 'contentAutoUpdate.enabled' && (value === true || value === 'true')) {
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', '0')
await KVStore.clearValue('contentAutoUpdate.autoDisabledReason')
await InstalledResource.query().update({
auto_update_consecutive_failures: 0,
auto_update_disabled_reason: null,
})
}
}
/**

View File

@ -0,0 +1,51 @@
import logger from '@adonisjs/core/services/logger'
import type InstalledResource from '#models/installed_resource'
/**
* Per-resource failure backoff for content (ZIM/map) auto-updates, shared by the
* three places that observe an auto-update's real lifecycle:
*
* - {@link ContentAutoUpdateService.attempt} a dispatch that fails to even
* enqueue (counts as a failure; no job runs so no terminal event follows).
* - `RunDownloadJob.onComplete` a download that actually finished (success).
* - the worker `failed` handler in `commands/queue/work.ts` a download that
* exhausted its retries (terminal failure).
*
* Kept in a dependency-light util (not on ContentAutoUpdateService) on purpose:
* RunDownloadJob is imported by CollectionUpdateService, which is imported by
* ContentAutoUpdateService, so importing the service back into the job would
* close an import cycle. Only the InstalledResource model and the logger are
* touched here.
*/
/** Genuine consecutive auto-update failures before a resource self-disables. */
export const MAX_CONSECUTIVE_FAILURES = 3
/** Clear a resource's failure backoff after a successful auto-update. */
export async function recordResourceUpdateSuccess(resource: InstalledResource): Promise<void> {
if (resource.auto_update_consecutive_failures === 0 && !resource.auto_update_disabled_reason) {
return
}
resource.auto_update_consecutive_failures = 0
resource.auto_update_disabled_reason = null
await resource.save()
}
/** Record an auto-update failure and self-disable the resource at the threshold. */
export async function recordResourceUpdateFailure(
resource: InstalledResource,
reason: string
): Promise<void> {
const failures = (resource.auto_update_consecutive_failures || 0) + 1
resource.auto_update_consecutive_failures = failures
if (failures >= MAX_CONSECUTIVE_FAILURES) {
resource.auto_update_disabled_reason = `Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
logger.error(
`[ContentAutoUpdate] ${resource.resource_id} auto-disabled after ${failures} failures`
)
}
await resource.save()
logger.error(
`[ContentAutoUpdate] ${resource.resource_id} failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}`
)
}

View File

@ -0,0 +1,58 @@
/**
* Decision for reconciling the AI knowledge base (Qdrant) after a curated
* content file (a ZIM) is replaced by a newer downloaded version.
*
* This is the pure, I/O-free core of `RagService.reconcileReplacedContentFile`.
* Keeping the branching here (mirrors `decideScanAction` in
* `kb_ingest_decision.ts`) makes the contract exhaustively testable without a
* database or a live Qdrant.
*
* The logic deliberately MIRRORS the replaced file's prior indexed state rather
* than applying the global `rag.defaultIngestPolicy`: on a content *update* the
* user has already made an indexing choice for this content, so we honor it in
* both directions (re-index a previously-indexed file even under Manual; leave a
* previously-unindexed file alone even under Always). Fresh installs still go
* through the normal policy path those return `not_a_replacement` here.
*
* Outcomes (evaluated top-down, short-circuiting):
* - `not_a_replacement` no prior file, or the new file has the same path
* (same-version re-download). Caller defers to normal ingest policy.
* - `qdrant_not_installed` (step 2) no knowledge base exists; nothing to do.
* - `old_not_indexed` (step 4) the replaced file was never embedded
* (no state row, or state `indexed`); leave the new file un-indexed.
* - `qdrant_not_running` (step 5) the replaced file WAS indexed but Qdrant is
* currently offline. We do nothing: we can't remove the stale points, and a
* queued embed job could be dropped before Qdrant returns. Acting half-way is
* wasteful, so we defer entirely (accepted tradeoff: stale points linger).
* - `reindex` (step 3) the replaced file was indexed and Qdrant is running:
* delete ONLY the old file's points, drop its state row, and queue the new
* file for embedding.
*
* Note the install-before-indexed ordering: step 2 short-circuits before any KB
* state lookup, matching the spec.
*/
export type ReindexOutcome =
| 'not_a_replacement'
| 'qdrant_not_installed'
| 'old_not_indexed'
| 'qdrant_not_running'
| 'reindex'
export interface ContentReindexInput {
/** The replaced file existed AND its path differs from the new file's path. */
isReplacement: boolean
/** `nomad_qdrant` service exists (installed), regardless of running state. */
qdrantInstalled: boolean
/** The replaced file's `KbIngestState.state === 'indexed'`. */
oldFileWasIndexed: boolean
/** Qdrant answered a live health check (currently reachable). */
qdrantRunning: boolean
}
export function decideContentReindex(input: ContentReindexInput): ReindexOutcome {
if (!input.isReplacement) return 'not_a_replacement'
if (!input.qdrantInstalled) return 'qdrant_not_installed'
if (!input.oldFileWasIndexed) return 'old_not_indexed'
if (!input.qdrantRunning) return 'qdrant_not_running'
return 'reindex'
}

View File

@ -22,17 +22,28 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
switch (key) {
case 'autoUpdate.windowStart':
case 'autoUpdate.windowEnd':
case 'contentAutoUpdate.windowStart':
case 'contentAutoUpdate.windowEnd':
if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) {
return 'Time window values must be in 24-hour HH:MM format (e.g. "20:00").'
}
return null
case 'autoUpdate.cooloffHours': {
case 'autoUpdate.cooloffHours':
case 'contentAutoUpdate.cooloffHours': {
const num = Number(value)
if (!Number.isInteger(num) || num < 0 || num > 8760) {
return 'Cool-off must be a whole number of hours between 0 and 8760.'
}
return null
}
case 'contentAutoUpdate.maxBytesPerWindow': {
// Per-window download budget in bytes. 0 = unlimited.
const num = Number(value)
if (!Number.isInteger(num) || num < 0) {
return 'The per-window data cap must be a whole number of bytes (0 = unlimited).'
}
return null
}
default:
return null
}

View File

@ -0,0 +1,218 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { isWithinWindow } from '../../app/utils/update_window.js'
/**
* Exercise the content auto-update decision pipeline WITHOUT ever dispatching a
* real download.
*
* # Prove the core selection/eligibility/window logic deterministically
* # (no network/DB):
* node ace content-auto-update:dry-run --scenarios
*
* # Evaluate what the next run would do against the currently-persisted
* # available-update state (run a "Check for Content Updates" first to refresh
* # it), forcing the feature on and overriding the cap:
* node ace content-auto-update:dry-run --force-enabled --cap=20 --window-start=00:00 --window-end=23:59
*/
export default class ContentAutoUpdateDryRun extends BaseCommand {
static commandName = 'content-auto-update:dry-run'
static description = 'Dry-run the content auto-update decision pipeline (never dispatches a download)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: boolean
@flags.boolean({ description: 'Ignore the persisted enabled setting and treat as enabled' })
declare forceEnabled: boolean
@flags.string({ description: 'Override cool-off hours' })
declare cooloff: string
@flags.string({ description: 'Override window start (HH:MM)' })
declare windowStart: string
@flags.string({ description: 'Override window end (HH:MM)' })
declare windowEnd: string
@flags.string({ description: 'Override per-window data cap in GB (0 = unlimited)' })
declare cap: string
@flags.string({ description: 'Override bytes already used this window' })
declare usedBytes: string
@flags.string({ description: 'Simulate the clock at this ISO timestamp' })
declare now: string
static options: CommandOptions = {
startApp: true,
}
async run() {
const { DateTime } = await import('luxon')
const { DownloadService } = await import('#services/download_service')
const { QueueService } = await import('#services/queue_service')
const { ContentAutoUpdateService } = await import('#services/content_auto_update_service')
const svc = new ContentAutoUpdateService(new DownloadService(QueueService.getInstance()))
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime)
if (!ok) this.exitCode = 1
return
}
const BYTES_PER_GB = 1024 * 1024 * 1024
const overrides: Record<string, any> = {}
if (this.forceEnabled) overrides.forceEnabled = true
if (this.cooloff) overrides.cooloffHours = Number(this.cooloff)
if (this.windowStart) overrides.windowStart = this.windowStart
if (this.windowEnd) overrides.windowEnd = this.windowEnd
if (this.cap) overrides.maxBytesPerWindow = Math.round(Number(this.cap) * BYTES_PER_GB)
if (this.usedBytes) overrides.windowBytesUsed = Number(this.usedBytes)
if (this.now) overrides.now = DateTime.fromISO(this.now)
this.logger.info('Running content auto-update dry run (no download will be dispatched)...')
const d = await svc.dryRun(overrides)
this.logger.log('')
this.logger.log(` Enabled : ${d.enabled}`)
this.logger.log(
` Window : ${d.config.windowStart}-${d.config.windowEnd} ` +
`(currently ${d.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${d.config.cooloffHours}`)
this.logger.log(
` Data cap : ${d.config.maxBytesPerWindow > 0 ? d.config.maxBytesPerWindow + ' bytes' : 'unlimited'}`
)
this.logger.log(` Eligible : ${d.eligibleCount}`)
this.logger.log(` Would start : ${d.selection.selected.map((c) => c.resource.resource_id).join(', ') || '—'}`)
this.logger.log(
` Skipped (cap) : ${d.selection.skippedOversize.map((c) => c.resource.resource_id).join(', ') || '—'}`
)
this.logger.log(
` Deferred (budget): ${d.selection.deferred.map((c) => c.resource.resource_id).join(', ') || '—'}`
)
this.logger.log('')
}
/**
* Deterministic acceptance suite over the pure decision helpers no network
* or dispatch. Mirrors the per-resource eligibility, cap selection, and window
* branches reviewers care about.
*/
private runScenarios(svc: any, DateTime: any): boolean {
const now = DateTime.fromISO('2026-06-04T03:00:00Z')
const daysAgo = (d: number) => now.minus({ days: d })
const hoursAgo = (h: number) => now.minus({ hours: h })
const res = (o: Record<string, any> = {}) => ({
resource_id: 'res',
version: '2024-01',
available_update_version: null,
available_update_size_bytes: null,
available_update_first_seen_at: null,
auto_update_disabled_reason: null,
auto_update_consecutive_failures: 0,
installed_at: daysAgo(100),
...o,
})
const cand = (id: string, size: number, installedAt: any = daysAgo(100)) => ({
resource: res({ resource_id: id }),
version: '2024-06',
download_url: `(test)`,
size_bytes: size,
installed_at: installedAt,
})
let passed = 0
let failed = 0
const report = (ok: boolean, message: string) => {
this.logger.log(` ${ok ? this.colors.green('✓') : this.colors.red('✗')} ${message}`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Eligibility scenarios:')
report(
svc.resourceEligibility(res(), 72, now).eligible === false,
'no available update → not eligible'
)
report(
svc.resourceEligibility(
res({ available_update_version: '2024-06', available_update_first_seen_at: hoursAgo(10) }),
72,
now
).eligible === false,
'inside cool-off → not eligible'
)
report(
svc.resourceEligibility(
res({ available_update_version: '2024-06', available_update_first_seen_at: daysAgo(5) }),
72,
now
).eligible === true,
'past cool-off → eligible'
)
report(
svc.resourceEligibility(
res({
available_update_version: '2024-06',
available_update_first_seen_at: daysAgo(30),
auto_update_disabled_reason: 'disabled',
}),
72,
now
).eligible === false,
'self-disabled → not eligible'
)
this.logger.log('')
this.logger.log('Cap selection scenarios:')
{
const s = svc.selectUnderCap([cand('a', 1000), cand('b', 2000)], 10000, 0)
report(s.selected.length === 2, 'under cap selects all')
}
{
const s = svc.selectUnderCap([cand('huge', 50000)], 20000, 0)
report(
s.selected.length === 0 && s.skippedOversize.length === 1,
'oversize file → skipped, never selected'
)
}
{
const s = svc.selectUnderCap([cand('mid', 8000)], 10000, 5000)
report(s.selected.length === 0 && s.deferred.length === 1, 'over remaining budget → deferred')
}
{
const s = svc.selectUnderCap([cand('a', 0)], 10000, 0)
report(s.selected.length === 0 && s.deferred.length === 1, 'unknown size → deferred')
}
{
const s = svc.selectUnderCap([cand('big', 9_999_999_999)], 0, 0)
report(s.selected.length === 1, 'cap 0 → unlimited')
}
this.logger.log('')
this.logger.log('Window scenarios:')
report(
isWithinWindow('02:00', '05:00', DateTime.fromISO('2026-06-04T03:00:00')) === true,
'normal 02:00-05:00 @ 03:00 → in'
)
report(
isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T01:00:00')) === true,
'wrap 22:00-02:00 @ 01:00 → in'
)
report(
isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T12:00:00')) === false,
'wrap 22:00-02:00 @ 12:00 → out'
)
this.logger.log('')
if (failed === 0) {
this.logger.success(`All ${passed} scenarios passed`)
} else {
this.logger.error(`${failed} scenario(s) failed, ${passed} passed`)
}
return failed === 0
}
}

View File

@ -11,6 +11,7 @@ import { CheckUpdateJob } from '#jobs/check_update_job'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { AutoUpdateJob } from '#jobs/auto_update_job'
import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
export default class QueueWork extends BaseCommand {
static commandName = 'queue:work'
@ -92,6 +93,36 @@ export default class QueueWork extends BaseCommand {
)
}
}
// Terminal failure of an AUTO content update → advance that resource's
// backoff (self-disables after MAX_CONSECUTIVE_FAILURES). BullMQ emits
// `failed` on every attempt, so gate on the final attempt to count each
// doomed download once, not once per retry. Manual downloads (auto !== true)
// are deliberately excluded.
const meta = job?.data?.resourceMetadata
const isTerminal = (job?.attemptsMade ?? 0) >= (job?.opts?.attempts ?? 1)
if (job?.name === RunDownloadJob.key && meta?.auto === true && isTerminal) {
try {
const { default: InstalledResource } = await import('#models/installed_resource')
const { recordResourceUpdateFailure } = await import(
'../../app/utils/content_auto_update_backoff.js'
)
const resource = await InstalledResource.query()
.where('resource_id', meta.resource_id)
.where('resource_type', job.data.filetype)
.first()
if (resource) {
await recordResourceUpdateFailure(
resource,
err instanceof Error ? err.message : String(err)
)
}
} catch (e: any) {
this.logger.error(
`[${queueName}] Failed to record content auto-update backoff: ${e.message}`
)
}
}
})
worker.on('completed', (job) => {
@ -107,6 +138,7 @@ export default class QueueWork extends BaseCommand {
await CheckServiceUpdatesJob.scheduleNightly()
await AutoUpdateJob.schedule()
await AppAutoUpdateJob.schedule()
await ContentAutoUpdateJob.schedule()
// Safety net: log unhandled rejections instead of crashing the worker process.
// Individual job errors are already caught by BullMQ; this catches anything that
@ -139,6 +171,7 @@ export default class QueueWork extends BaseCommand {
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob())
handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob())
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue)
@ -149,6 +182,7 @@ export default class QueueWork extends BaseCommand {
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
queues.set(AutoUpdateJob.key, AutoUpdateJob.queue)
queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue)
queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue)
return [handlers, queues]
}

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];

View File

@ -0,0 +1,36 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'installed_resources'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// The newest catalog version detected for this resource (a YYYY-MM date
// stamp), or null when the installed copy is already current. Written by
// every freshness check (manual or auto) via reconcileResourceUpdateState.
table.string('available_update_version').nullable()
// Size of the available update (bytes), captured from the catalog so the
// status UI and the per-window data-cap selection don't need to re-query
// the mirror on every poll.
table.bigInteger('available_update_size_bytes').nullable()
// Cool-off anchor: when the currently-available update was first detected.
// ZIM/map versions carry no publish date we can trust, so cool-off is
// measured from first-seen (mirrors the per-app auto-update fields).
table.timestamp('available_update_first_seen_at').nullable()
// Per-resource failure backoff so one flapping download self-disables
// without affecting the rest of the auto-update run.
table.integer('auto_update_consecutive_failures').notNullable().defaultTo(0)
table.string('auto_update_disabled_reason', 255).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('available_update_version')
table.dropColumn('available_update_size_bytes')
table.dropColumn('available_update_first_seen_at')
table.dropColumn('auto_update_consecutive_failures')
table.dropColumn('auto_update_disabled_reason')
})
}
}

View File

@ -0,0 +1,249 @@
import { useEffect, useState } from 'react'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import Alert from '~/components/Alert'
import api from '~/lib/api'
import Input from '~/components/inputs/Input'
import Switch from '~/components/inputs/Switch'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { useContentAutoUpdateStatus } from '~/hooks/useContentAutoUpdateStatus'
import { formatBytes } from '~/lib/util'
const COOLOFF_OPTIONS = [
{ value: 24, label: '24 hours (1 day)' },
{ value: 48, label: '48 hours (2 days)' },
{ value: 72, label: '72 hours (3 days)' },
{ value: 168, label: '7 days' },
]
const BYTES_PER_GB = 1024 * 1024 * 1024
export default function ContentAutoUpdateSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const { data: status, isLoading } = useContentAutoUpdateStatus()
const [windowStart, setWindowStart] = useState('02:00')
const [windowEnd, setWindowEnd] = useState('05:00')
const [cooloff, setCooloff] = useState(72)
// Data cap is stored in bytes but edited in GB (0 = unlimited).
const [capGb, setCapGb] = useState('0')
// Seed editable fields once the persisted status loads.
useEffect(() => {
if (status) {
setWindowStart(status.windowStart)
setWindowEnd(status.windowEnd)
setCooloff(status.cooloffHours)
setCapGb(
status.maxBytesPerWindow > 0
? String(Math.round((status.maxBytesPerWindow / BYTES_PER_GB) * 100) / 100)
: '0'
)
}
}, [status?.windowStart, status?.windowEnd, status?.cooloffHours, status?.maxBytesPerWindow])
const enabled = status?.enabled ?? false
const autoDisabled = !!status?.autoDisabledReason
const toggleMutation = useMutation({
mutationFn: (value: boolean) => api.updateSetting('contentAutoUpdate.enabled', value),
onSuccess: (_data, value) => {
queryClient.invalidateQueries({ queryKey: ['content-auto-update-status'] })
addNotification({
type: 'success',
message: value
? 'Automatic content updates enabled.'
: 'Automatic content updates disabled.',
})
},
onError: () => {
addNotification({ type: 'error', message: 'Failed to update content auto-update setting.' })
},
})
const handleSaveSchedule = async () => {
const parsedGb = Number(capGb)
if (!Number.isFinite(parsedGb) || parsedGb < 0) {
addNotification({ type: 'error', message: 'Data cap must be 0 or a positive number of GB.' })
return
}
const capBytes = Math.round(parsedGb * BYTES_PER_GB)
try {
await api.updateSetting('contentAutoUpdate.windowStart', windowStart)
await api.updateSetting('contentAutoUpdate.windowEnd', windowEnd)
await api.updateSetting('contentAutoUpdate.cooloffHours', String(cooloff))
await api.updateSetting('contentAutoUpdate.maxBytesPerWindow', String(capBytes))
queryClient.invalidateQueries({ queryKey: ['content-auto-update-status'] })
addNotification({ type: 'success', message: 'Content update schedule saved.' })
} catch {
addNotification({ type: 'error', message: 'Failed to save content update schedule.' })
}
}
return (
<>
<StyledSectionHeader title="Automatic Content Updates" className="mt-8" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
{autoDisabled && (
<Alert
type="warning"
title="Automatic Content Updates Disabled"
message={
status?.autoDisabledReason ||
'Automatic content updates were disabled after repeated failures.'
}
variant="bordered"
className="mb-4"
/>
)}
<Switch
checked={enabled}
onChange={(value) => toggleMutation.mutate(value)}
disabled={toggleMutation.isPending || isLoading}
label="Enable Automatic Content Updates"
description="Automatically download newer versions of your installed Information Library content (ZIM files) and maps during your chosen window. Content downloads can be very large, so set a per-window data cap to limit how much is pulled at once. We recommend allowing at least 0.5 GB per update window to ensure most updates can be pulled in a timely manner, but you can set a lower cap if you have very limited bandwidth and don't mind some updates being skipped (they will still appear in the UI and can be updated manually). If an update repeatedly fails to download within the window, it will be automatically disabled and require manual intervention to re-enable."
/>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Input
name="contentWindowStart"
label="Window Start"
type="time"
value={windowStart}
onChange={(e) => setWindowStart(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<Input
name="contentWindowEnd"
label="Window End"
type="time"
value={windowEnd}
onChange={(e) => setWindowEnd(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<div>
<label
htmlFor="contentCooloff"
className="block text-base/6 font-medium text-text-primary"
>
Cool-off Period
</label>
<p className="mt-1 text-sm text-text-muted">Delay after a new version appears</p>
<select
id="contentCooloff"
value={cooloff}
onChange={(e) => setCooloff(Number(e.target.value))}
disabled={!enabled}
className="mt-1.5 block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 disabled:opacity-50"
>
{COOLOFF_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<Input
name="contentDataCap"
label="Data Cap (GB)"
type="number"
min="0"
step="1"
value={capGb}
onChange={(e) => setCapGb(e.target.value)}
disabled={!enabled}
helpText="Per window. 0 = unlimited"
/>
</div>
<div className="mt-4 flex justify-end">
<StyledButton variant="primary" size="sm" onClick={handleSaveSchedule} disabled={!enabled}>
Save Schedule
</StyledButton>
</div>
{enabled && status && (
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm">
<p className="text-desert-stone mb-3">
<span className="font-medium">Update window: </span>
{status.windowStart}{status.windowEnd} (
{status.withinWindow ? 'currently inside' : 'currently outside'}); cool-off{' '}
{status.cooloffHours}h; data cap{' '}
{status.maxBytesPerWindow > 0 ? formatBytes(status.maxBytesPerWindow) : 'unlimited'}
{status.maxBytesPerWindow > 0 && (
<> ({formatBytes(status.windowBytesUsed)} used this window)</>
)}
.
{status.lastResult && (
<>
{' '}
<span className="font-medium">Last run: </span>
{status.lastResult}
{status.lastAttemptAt
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
: ''}
</>
)}
</p>
{status.lastError && (
<p className="text-desert-red mb-3">
<span className="font-medium">Last error: </span>
{status.lastError}
</p>
)}
{status.resources.length === 0 ? (
<p className="text-desert-stone-dark">
All installed content is up to date. New versions will appear here when detected.
</p>
) : (
<ul className="space-y-2">
{status.resources.map((resource) => (
<li
key={`${resource.resource_type}:${resource.resource_id}`}
className="flex items-start justify-between gap-4 rounded-md bg-surface-secondary px-3 py-2"
>
<div>
<p className="font-medium text-text-primary">
{resource.resource_id}{' '}
<span className="text-xs uppercase text-desert-stone">
{resource.resource_type}
</span>
</p>
<p className="text-desert-stone">
{resource.current_version}
{resource.available_update_version
? `${resource.available_update_version}`
: ' (up to date)'}
{resource.size_bytes ? ` · ${formatBytes(resource.size_bytes)}` : ''}
</p>
{resource.auto_disabled_reason && (
<p className="text-desert-red mt-0.5">{resource.auto_disabled_reason}</p>
)}
</div>
<span
className={`shrink-0 text-xs font-medium ${resource.exceeds_cap
? 'text-desert-red'
: resource.eligible
? 'text-desert-green'
: 'text-desert-stone'
}`}
>
{resource.exceeds_cap ? 'Skipped — exceeds data cap, update manually' : resource.reason}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
</>
)
}

View File

@ -99,7 +99,7 @@ export default function ContentUpdatesSection() {
return (
<div className="mt-8">
<StyledSectionHeader title="Content Updates" />
<StyledSectionHeader title="Manual Content Updates" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between">

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import api from '~/lib/api'
import { ContentAutoUpdateStatus } from '../../types/system'
export const useContentAutoUpdateStatus = () => {
return useQuery<ContentAutoUpdateStatus | undefined>({
queryKey: ['content-auto-update-status'],
queryFn: () => api.getContentAutoUpdateStatus(),
refetchOnWindowFocus: false,
})
}

View File

@ -2,7 +2,7 @@ import axios, { AxiosError, AxiosInstance } from 'axios'
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files'
import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, ContentAutoUpdateStatus, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps'
import { EmbedJobWithProgress, FileWarningsResult, StoredFileInfo } from '../../types/rag'
@ -556,6 +556,15 @@ class API {
})()
}
async getContentAutoUpdateStatus() {
return catchInternal(async () => {
const response = await this.client.get<ContentAutoUpdateStatus>(
'/system/content/auto-update/status'
)
return response.data
})()
}
async setServiceAutoUpdate(serviceName: string, enabled: boolean) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(

View File

@ -14,6 +14,7 @@ import { useNotifications } from '~/context/NotificationContext'
import { useSystemSetting } from '~/hooks/useSystemSetting'
import CoreAutoUpdateSection from '~/components/updates/CoreAutoUpdateSection'
import AppAutoUpdateSection from '~/components/updates/AppAutoUpdateSection'
import ContentAutoUpdateSection from '~/components/updates/ContentAutoUpdateSection'
import ContentUpdatesSection from '~/components/updates/ContentUpdatesSection'
type Props = {
@ -453,6 +454,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
</div>
<CoreAutoUpdateSection />
<AppAutoUpdateSection />
<ContentAutoUpdateSection />
<ContentUpdatesSection />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden py-6 mt-12">
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">

View File

@ -185,6 +185,7 @@ router
router.post('/services/update', [SystemController, 'updateService'])
router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate'])
router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus'])
router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus'])
router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
router.post('/update', [SystemController, 'requestSystemUpdate'])

View File

@ -0,0 +1,218 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { DateTime } from 'luxon'
import {
ContentAutoUpdateService,
type ContentCandidate,
} from '../../app/services/content_auto_update_service.js'
import { isWithinWindow } from '../../app/utils/update_window.js'
// resourceEligibility + selectUnderCap are pure; the I/O constructor deps are
// irrelevant for these tests, so null them out.
const svc = new ContentAutoUpdateService(null as any, null as any, null as any)
const NOW = DateTime.fromISO('2026-06-04T03:00:00Z')
const daysAgo = (d: number) => NOW.minus({ days: d })
const hoursAgo = (h: number) => NOW.minus({ hours: h })
function makeResource(overrides: Record<string, any> = {}) {
return {
resource_id: 'wikipedia_en_all_maxi',
resource_type: 'zim',
version: '2024-01',
available_update_version: null,
available_update_size_bytes: null,
available_update_first_seen_at: null,
auto_update_disabled_reason: null,
auto_update_consecutive_failures: 0,
installed_at: daysAgo(100),
...overrides,
} as any
}
function makeCandidate(overrides: Partial<ContentCandidate> & { id?: string } = {}): ContentCandidate {
const { id, ...rest } = overrides
return {
resource: makeResource({ resource_id: id ?? 'res' }) as any,
version: '2024-06',
download_url: `https://download.kiwix.org/zim/${id ?? 'res'}_2024-06.zim`,
size_bytes: 1_000,
installed_at: daysAgo(100),
...rest,
}
}
// ── Per-resource eligibility ───────────────────────────────────────────────────
test('no available update → not eligible (up to date)', () => {
const v = svc.resourceEligibility(makeResource(), 72, NOW)
assert.equal(v.eligible, false)
assert.match(v.reason, /up to date/i)
})
test('newer version but still inside cool-off → not eligible', () => {
const v = svc.resourceEligibility(
makeResource({
available_update_version: '2024-06',
available_update_first_seen_at: hoursAgo(10),
}),
72,
NOW
)
assert.equal(v.eligible, false)
assert.match(v.reason, /cool-off/i)
})
test('newer version past cool-off → eligible', () => {
const v = svc.resourceEligibility(
makeResource({
available_update_version: '2024-06',
available_update_first_seen_at: daysAgo(5),
}),
72,
NOW
)
assert.equal(v.eligible, true)
})
test('null first-seen → not eligible (cool-off pending)', () => {
const v = svc.resourceEligibility(
makeResource({ available_update_version: '2024-06', available_update_first_seen_at: null }),
72,
NOW
)
assert.equal(v.eligible, false)
assert.match(v.reason, /cool-off pending/i)
})
test('self-disabled resource → not eligible regardless of cool-off', () => {
const v = svc.resourceEligibility(
makeResource({
available_update_version: '2024-06',
available_update_first_seen_at: daysAgo(30),
auto_update_disabled_reason: 'Auto-update disabled after 3 consecutive failures.',
}),
72,
NOW
)
assert.equal(v.eligible, false)
})
test('available equal to current (not newer) → not eligible', () => {
const v = svc.resourceEligibility(
makeResource({
available_update_version: '2024-01',
available_update_first_seen_at: daysAgo(30),
}),
72,
NOW
)
assert.equal(v.eligible, false)
})
test('cool-off of 0 applies immediately', () => {
const v = svc.resourceEligibility(
makeResource({
available_update_version: '2024-06',
available_update_first_seen_at: hoursAgo(1),
}),
0,
NOW
)
assert.equal(v.eligible, true)
})
// ── Cap-bounded selection ──────────────────────────────────────────────────────
test('under cap selects everything', () => {
const candidates = [
makeCandidate({ id: 'a', size_bytes: 1_000 }),
makeCandidate({ id: 'b', size_bytes: 2_000 }),
]
const { selected, skippedOversize, deferred } = svc.selectUnderCap(candidates, 10_000, 0)
assert.equal(selected.length, 2)
assert.equal(skippedOversize.length, 0)
assert.equal(deferred.length, 0)
})
test('cap 0 means unlimited', () => {
const candidates = [makeCandidate({ id: 'a', size_bytes: 9_999_999_999 })]
const { selected, skippedOversize } = svc.selectUnderCap(candidates, 0, 0)
assert.equal(selected.length, 1)
assert.equal(skippedOversize.length, 0)
})
test('single file larger than the whole cap → skippedOversize, never selected', () => {
const candidates = [makeCandidate({ id: 'huge', size_bytes: 50_000 })]
const { selected, skippedOversize, deferred } = svc.selectUnderCap(candidates, 20_000, 0)
assert.equal(selected.length, 0)
assert.equal(skippedOversize.length, 1)
assert.equal(deferred.length, 0)
})
test('fits the cap but not this windows remaining budget → deferred', () => {
const candidates = [makeCandidate({ id: 'mid', size_bytes: 8_000 })]
// cap 10k, already used 5k → only 5k remaining; 8k fits the cap but not the budget.
const { selected, deferred, skippedOversize } = svc.selectUnderCap(candidates, 10_000, 5_000)
assert.equal(selected.length, 0)
assert.equal(deferred.length, 1)
assert.equal(skippedOversize.length, 0)
})
test('greedy oldest-first selection packs until budget exhausted', () => {
const candidates = [
makeCandidate({ id: 'new', size_bytes: 4_000, installed_at: daysAgo(1) }),
makeCandidate({ id: 'old', size_bytes: 4_000, installed_at: daysAgo(50) }),
makeCandidate({ id: 'mid', size_bytes: 4_000, installed_at: daysAgo(25) }),
]
const { selected, deferred } = svc.selectUnderCap(candidates, 10_000, 0)
// 10k budget / 4k each → 2 selected (oldest two), 1 deferred.
assert.equal(selected.length, 2)
assert.equal(deferred.length, 1)
assert.deepEqual(
selected.map((c) => c.resource.resource_id),
['old', 'mid']
)
})
test('budget already exhausted → everything deferred', () => {
const candidates = [makeCandidate({ id: 'a', size_bytes: 1_000 })]
const { selected, deferred } = svc.selectUnderCap(candidates, 5_000, 5_000)
assert.equal(selected.length, 0)
assert.equal(deferred.length, 1)
})
test('unknown size (0) → deferred, never selected', () => {
const candidates = [makeCandidate({ id: 'a', size_bytes: 0 })]
const { selected, deferred, skippedOversize } = svc.selectUnderCap(candidates, 10_000, 0)
assert.equal(selected.length, 0)
assert.equal(deferred.length, 1)
assert.equal(skippedOversize.length, 0)
})
// ── Window boundary math (per-window budget reset) ─────────────────────────────
test('windowStartBoundary: same-day window resolves to todays start', () => {
const now = DateTime.fromISO('2026-06-04T03:30:00')
const boundary = svc.windowStartBoundary('02:00', now)
assert.equal(boundary.toISO(), DateTime.fromISO('2026-06-04T02:00:00').toISO())
})
test('windowStartBoundary: before todays start resolves to yesterday (wrap window)', () => {
const now = DateTime.fromISO('2026-06-04T01:00:00')
const boundary = svc.windowStartBoundary('22:00', now)
assert.equal(boundary.toISO(), DateTime.fromISO('2026-06-03T22:00:00').toISO())
})
// ── Shared update window ───────────────────────────────────────────────────────
test('window: normal 02:00-05:00 includes 03:00, excludes 06:00', () => {
assert.equal(isWithinWindow('02:00', '05:00', DateTime.fromISO('2026-06-04T03:00:00')), true)
assert.equal(isWithinWindow('02:00', '05:00', DateTime.fromISO('2026-06-04T06:00:00')), false)
})
test('window: midnight-wrapping 22:00-02:00 includes 01:00, excludes 12:00', () => {
assert.equal(isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T01:00:00')), true)
assert.equal(isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T12:00:00')), false)
})

View File

@ -0,0 +1,83 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import {
MAX_CONSECUTIVE_FAILURES,
recordResourceUpdateFailure,
recordResourceUpdateSuccess,
} from '../../app/utils/content_auto_update_backoff.js'
// The helpers only read/write two columns and call save(); a plain object with a
// save spy stands in for the Lucid model so the backoff logic is testable without
// a database.
function makeResource(overrides: Record<string, any> = {}) {
let saved = 0
return {
resource_id: 'wikipedia_en_all_maxi',
auto_update_consecutive_failures: 0,
auto_update_disabled_reason: null as string | null,
async save() {
saved++
},
get saveCount() {
return saved
},
...overrides,
} as any
}
// ── Failure backoff ─────────────────────────────────────────────────────────────
test('failure increments the consecutive counter', async () => {
const r = makeResource()
await recordResourceUpdateFailure(r, 'network error')
assert.equal(r.auto_update_consecutive_failures, 1)
assert.equal(r.auto_update_disabled_reason, null)
})
test('failures below the threshold do not self-disable', async () => {
const r = makeResource({ auto_update_consecutive_failures: 1 })
await recordResourceUpdateFailure(r, 'network error')
assert.equal(r.auto_update_consecutive_failures, 2)
assert.equal(r.auto_update_disabled_reason, null)
})
test('reaching the threshold self-disables with the last error in the reason', async () => {
const r = makeResource({ auto_update_consecutive_failures: MAX_CONSECUTIVE_FAILURES - 1 })
await recordResourceUpdateFailure(r, 'disk full')
assert.equal(r.auto_update_consecutive_failures, MAX_CONSECUTIVE_FAILURES)
assert.match(r.auto_update_disabled_reason, /disabled after 3 consecutive failures/i)
assert.match(r.auto_update_disabled_reason, /disk full/)
})
test('failure always persists', async () => {
const r = makeResource()
await recordResourceUpdateFailure(r, 'boom')
assert.equal(r.saveCount, 1)
})
// ── Success reset ───────────────────────────────────────────────────────────────
test('success clears the counter and the disabled reason', async () => {
const r = makeResource({
auto_update_consecutive_failures: 3,
auto_update_disabled_reason: 'Auto-update disabled after 3 consecutive failures.',
})
await recordResourceUpdateSuccess(r)
assert.equal(r.auto_update_consecutive_failures, 0)
assert.equal(r.auto_update_disabled_reason, null)
assert.equal(r.saveCount, 1)
})
test('success is a no-op (no save) when already clean', async () => {
const r = makeResource({ auto_update_consecutive_failures: 0, auto_update_disabled_reason: null })
await recordResourceUpdateSuccess(r)
assert.equal(r.saveCount, 0)
})
test('success still resets when a stray reason lingers at zero failures', async () => {
const r = makeResource({ auto_update_consecutive_failures: 0, auto_update_disabled_reason: 'stale' })
await recordResourceUpdateSuccess(r)
assert.equal(r.auto_update_disabled_reason, null)
assert.equal(r.saveCount, 1)
})

View File

@ -0,0 +1,64 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import {
decideContentReindex,
type ContentReindexInput,
} from '../../app/utils/content_reindex_decision.js'
// Default to the fully-actionable case; each test overrides one axis so the
// short-circuit ordering is exercised explicitly.
function input(overrides: Partial<ContentReindexInput> = {}): ContentReindexInput {
return {
isReplacement: true,
qdrantInstalled: true,
oldFileWasIndexed: true,
qdrantRunning: true,
...overrides,
}
}
test('fresh install / same-path re-download → not_a_replacement (defers to normal policy)', () => {
assert.equal(decideContentReindex(input({ isReplacement: false })), 'not_a_replacement')
})
test('not_a_replacement short-circuits before any Qdrant consideration', () => {
// Even with everything else "off", a non-replacement is the controlling answer.
assert.equal(
decideContentReindex(
input({ isReplacement: false, qdrantInstalled: false, oldFileWasIndexed: false, qdrantRunning: false })
),
'not_a_replacement'
)
})
test('replacement but Qdrant not installed → qdrant_not_installed (step 2)', () => {
assert.equal(decideContentReindex(input({ qdrantInstalled: false })), 'qdrant_not_installed')
})
test('not-installed short-circuits before the old-indexed lookup', () => {
// oldFileWasIndexed should not matter once Qdrant is absent.
assert.equal(
decideContentReindex(input({ qdrantInstalled: false, oldFileWasIndexed: true })),
'qdrant_not_installed'
)
})
test('replacement, installed, but old file was never indexed → old_not_indexed (step 4)', () => {
assert.equal(decideContentReindex(input({ oldFileWasIndexed: false })), 'old_not_indexed')
})
test('old not indexed wins even if Qdrant is running (respects prior un-indexed choice)', () => {
assert.equal(
decideContentReindex(input({ oldFileWasIndexed: false, qdrantRunning: true })),
'old_not_indexed'
)
})
test('old indexed but Qdrant currently down → qdrant_not_running (step 5, defer entirely)', () => {
assert.equal(decideContentReindex(input({ qdrantRunning: false })), 'qdrant_not_running')
})
test('replacement + installed + old indexed + running → reindex (step 3)', () => {
assert.equal(decideContentReindex(input()), 'reindex')
})

View File

@ -41,6 +41,13 @@ export type RunDownloadJobParams = Omit<
resource_id: string
version: string
collection_ref: string | null
/**
* True only when this download was initiated by the content auto-updater.
* Gates the per-resource auto-update backoff (success reset in
* RunDownloadJob.onComplete, terminal-failure increment in the worker
* `failed` handler) so manual downloads never touch the counter.
*/
auto?: boolean
}
}

View File

@ -19,6 +19,18 @@ export const KV_STORE_SCHEMA = {
'appAutoUpdate.enabled': 'boolean',
'appAutoUpdate.lastAttemptAt': 'string',
'appAutoUpdate.lastResult': 'string',
'contentAutoUpdate.enabled': 'boolean',
'contentAutoUpdate.windowStart': 'string',
'contentAutoUpdate.windowEnd': 'string',
'contentAutoUpdate.cooloffHours': 'string',
'contentAutoUpdate.maxBytesPerWindow': 'string',
'contentAutoUpdate.lastAttemptAt': 'string',
'contentAutoUpdate.lastResult': 'string',
'contentAutoUpdate.lastError': 'string',
'contentAutoUpdate.consecutiveFailures': 'string',
'contentAutoUpdate.autoDisabledReason': 'string',
'contentAutoUpdate.windowBytesUsed': 'string',
'contentAutoUpdate.windowResetAt': 'string',
'ui.hasVisitedEasySetup': 'boolean',
'ui.theme': 'string',
'ai.assistantCustomName': 'string',

View File

@ -131,4 +131,33 @@ export type AppAutoUpdateStatus = {
lastAttemptAt: string | null
lastResult: string | null
apps: AppAutoUpdateAppStatus[]
}
export type ContentAutoUpdateResourceStatus = {
resource_id: string
resource_type: 'zim' | 'map'
current_version: string
available_update_version: string | null
size_bytes: number | null
eligible: boolean
reason: string
cooloff_remaining_hours: number | null
exceeds_cap: boolean
consecutive_failures: number
auto_disabled_reason: string | null
}
export type ContentAutoUpdateStatus = {
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
maxBytesPerWindow: number
withinWindow: boolean
windowBytesUsed: number
lastAttemptAt: string | null
lastResult: string | null
lastError: string | null
autoDisabledReason: string | null
resources: ContentAutoUpdateResourceStatus[]
}